package net.heartshield.data;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Base64;
import android.util.Log;

import com.android.volley.DefaultRetryPolicy;
import com.android.volley.NetworkError;
import com.android.volley.Request;
import com.android.volley.RequestQueue;
import com.android.volley.Response;
import com.android.volley.ServerError;
import com.android.volley.TimeoutError;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonObjectRequest;
import com.android.volley.toolbox.StringRequest;
import com.android.volley.toolbox.Volley;
import com.google.gson.JsonSyntaxException;

import net.heartshield.util.JsonUtil;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Implements local and remote data interface to store and classify Measurements.
 *
 * TODO: https://developer.android.com/training/sync-adapters/creating-sync-adapter.html
 *
 * @author David Madl (git@abanbytes.eu)
 * @date 2017-04-10
 */
public class MeasurementDataManager implements IMeasurementDataManager {
    private static final String TAG = "MeasurementDataManager";

    private static MeasurementDataManager sInstance = null;
    private RequestQueue mRequestQueue;
    private FileUtil mFileUtil;
    private MeasurementCache mCache;
    private boolean mUseTestServer = false;

    private HandlerThread mBackgroundThread;
    private Handler mBackgroundHandler;

    private HandlerThread mBackgroundSyncThread;
    private Handler mBackgroundSyncHandler;

    private Map<String, Measurement> mDataCache = new HashMap<>();

    /** currently syncing measurements (used to avoid duplicate sync in SyncTaskQueue) */
    private Set<String> mBusyMeasurements = new HashSet<>();
    private Set<String> mBusyAudio = new HashSet<>();

    private int mNumFailedMeasurements = 0;

    public void addFailedMeasurement() {
        mNumFailedMeasurements++;
    }
    public int getNumFailedMeasurements() {
        return mNumFailedMeasurements;
    }

    private SyncTaskQueue mSyncQueue;

    private MeasurementDataManager(Context context) {
        mRequestQueue = Volley.newRequestQueue(context);
        mFileUtil = new FileUtil(context.getExternalFilesDir(null));

        mFileUtil.makeDirectories(FileUtil.PERSISTENT_DATA_DIR);

        try {
            mCache = MeasurementCache.load(mFileUtil);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public synchronized void startBackgroundThread() {
        if(mBackgroundThread != null)
            return;

        mBackgroundThread = new HandlerThread("MDMBackground");
        mBackgroundThread.start();
        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
    }

    public void startBackgroundSync() {
        if(mBackgroundSyncThread != null)
            return;

        mBackgroundSyncThread = new HandlerThread("MDMBackgroundSync");
        mBackgroundSyncThread.start();
        mBackgroundSyncHandler = new Handler(mBackgroundSyncThread.getLooper());

        mSyncQueue = new SyncTaskQueue(this, mBackgroundSyncHandler);
        mSyncQueue.setListener(mSyncListener);

        mBackgroundSyncHandler.post(new Runnable() {
            @Override
            public void run() {
                mSyncQueue.start();
            }
        });
    }

    public synchronized void stopBackgroundThread() {
        if(mBackgroundThread != null) {
            mBackgroundThread.quitSafely();

            // do not join = do not delay onPause(). thread may run until it's finished.
            mBackgroundHandler = null;
            mBackgroundThread = null;

            if(mSyncQueue != null)
                mSyncQueue.cancel();
            setSyncListener(null); // disable further sync messages
        }

        if(mBackgroundSyncThread != null) {
            mBackgroundSyncThread.quitSafely();
            // do not join = run until finished.
            mBackgroundSyncHandler = null;
            mBackgroundSyncThread = null;
        }
    }

    private SyncListener mSyncListener;

    @Override
    public void setSyncListener(SyncListener listener) {
        mSyncListener = listener;
        if(mSyncQueue != null)
            mSyncQueue.setListener(mSyncListener);
    }

    /**
     * Maintains a persistent list of measurements for quick summary access of past measurements.
     */
    private static class MeasurementCache {
        /** in ascending chronological order */
        MeasurementSummary[] measurements;
        private FileUtil mFileUtil;
        private MeasurementSummaryListener mListener;

        public MeasurementCache(FileUtil fileUtil) {
            // TODO(david): streamline MeasurementCache code. pass in filename into ctor, make it manage itself.
            mFileUtil = fileUtil;
            // needed for MeasurementCache
            mFileUtil.makeDirectories(FileUtil.PERSISTENT_DATA_DIR);
            measurements = new MeasurementSummary[0];
        }

        public String serialize() {
            return JsonUtil.getGson().toJson(this);
        }

        public static MeasurementCache deserialize(String json) {
            return JsonUtil.getGson().fromJson(json, MeasurementCache.class);
        }

        public void add(Measurement measurement) throws IOException {
            MeasurementSummary summary = measurement.getSummary();
            summary.number = measurements.length + 1;

            if(indexOf(summary.getId()) != -1)
                return; // TODO(david): once update() is implemented in MeasurementDataManager, reinstate the exception
                // silently ignore for now.
                //throw new IllegalArgumentException("we already have the measurement " + summary.getId().toString() + " added");

            MeasurementSummary[] newMeasurements = new MeasurementSummary[measurements.length+1];
            System.arraycopy(measurements, 0, newMeasurements, 0, measurements.length);
            newMeasurements[measurements.length] = summary;
            measurements = newMeasurements;

            flush();
            if(mListener != null)
                mListener.onMeasurementCreated(measurement);
        }

        private int indexOf(MeasurementId id) {
            int i = 0;
            for(; i < measurements.length; i++)
                if(measurements[i].getId().equals(id))
                    return i;
            return -1; // not found
        }

        public boolean remove(MeasurementId id) throws IOException {
            // find
            int i = indexOf(id);
            if(i == -1)
                return false;
                //throw new IllegalStateException("could not find measurement " + measurement.getId().toString() + " to remove."); // should we add() in this case?

            MeasurementSummary[] newMeasurements = new MeasurementSummary[measurements.length-1];
            System.arraycopy(measurements, 0, newMeasurements, 0, i);
            if(i < newMeasurements.length)
                System.arraycopy(measurements, i+1, newMeasurements, i, (newMeasurements.length - i));

            measurements = newMeasurements;

            flush();
            return true;
        }

        public void update(Measurement measurement) throws IOException {
            MeasurementSummary summary = measurement.getSummary();
            // find
            int i = indexOf(summary.getId());
            if(i == -1)
                throw new IllegalStateException("could not find measurement " + measurement.getId().toString() + " to update."); // should we add() in this case?

            summary.number = measurements[i].number; // keep the running number
            measurements[i] = summary;

            flush();
            if(mListener != null)
                mListener.onMeasurementUpdated(measurement);
        }

        public int size() { return measurements.length; }

        public static MeasurementCache load(FileUtil fileUtil) throws IOException {
            MeasurementCache cache;
            try {
                String json = fileUtil.readDataFileString(FileUtil.MEASUREMENT_CACHE);
                cache = MeasurementCache.deserialize(json);
                cache.mFileUtil = fileUtil;
            } catch(FileNotFoundException e) {
                // write an empty cache if no file exists yet
                cache = new MeasurementCache(fileUtil);
                cache.flush();
            }
            return cache;
        }

        void flush() throws IOException {
            mFileUtil.writeDataFileContents(FileUtil.MEASUREMENT_CACHE, serialize());
        }

        public void setSummaryListener(MeasurementSummaryListener listener) {
            mListener = listener;
        }
    }

    @Override
    public boolean isBusy(Measurement measurement) {
        String id = measurement.getId().toString();
        return mBusyMeasurements.contains(id) || mBusyAudio.contains(id);
    }

    public void setBusy(Measurement measurement, boolean busy) {
        if(busy)
            mBusyMeasurements.add(measurement.getId().toString());
        else
            mBusyMeasurements.remove(measurement.getId().toString());
    }
    public void setBusyAudio(Measurement measurement, boolean busy) {
        if(busy)
            mBusyAudio.add(measurement.getId().toString());
        else
            mBusyAudio.remove(measurement.getId().toString());
    }

    static class SyncTaskQueue {
        private IMeasurementDataManager mManager;
        private List<MeasurementSummary> mList;
        private int mI;
        private State mState;
        private Measurement mMeasurement;
        private SyncListener mListener;
        private int mSynced;
        private Handler mHandler;
        private boolean mRun = false;

        public void setListener(SyncListener listener) { mListener = listener; }

        private void dispatchProgress(int synced, int candidates) {
            if(mListener != null)
                mListener.onProgress(synced, candidates);
        }
        private void dispatchFinished(int synced, int candidates) {
            if(mListener != null)
                mListener.onFinished(synced, candidates);
        }

        SyncTaskQueue(IMeasurementDataManager manager, Handler handler) {
            mManager = manager;
            mHandler = handler;
            reset();
        }

        void start() {
            if(mList != null)
                throw new IllegalStateException("repeated start() called before sync finished");
            reset();
            mRun = true;

            updateList();

            dispatchProgress(0, mList.size());

            Log.i(TAG, "SyncTaskQueue has " + mList.size() + " unsynced measurements.");
            if(mList.size() > 0)
                next();
            else
                finish();
        }

        private void updateList() {
            mList = new ArrayList<>();
            MeasurementSummary[] list = mManager.list();
            for(MeasurementSummary summary : list) {
                if(!summary.isSynced())
                    mList.add(summary);
            }
        }

        enum State {
            SEND_MEASUREMENT,
            SEND_AUDIO,
            DONE
        }

        /** stops syncing after finishing sync of the current measurement */
        void cancel() {
            mRun = false;
        }

        void next() {
            if(!mRun)
                return;

            if(mHandler != null) {
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        runNext();
                    }
                });
            } else {
                runNext();
            }
        }

        private void runNext() {
            MeasurementSummary summary = mList.get(mI);
            Log.i(TAG, "SyncTaskQueue next() Measurement " + mI + "/" + mList.size());

            // back up and toggle
            State state = mState;

            switch(mState) {
                case SEND_MEASUREMENT: mState = State.SEND_AUDIO; break;
                case SEND_AUDIO: mState = State.DONE; break;
                default: throw new IllegalStateException("invalid location reached in this state");
            }

            try {
                //Measurement measurement = mManager.retrieve(summary.getId());

                if(state == State.SEND_MEASUREMENT) {
                    // note: retrieve() has an mDataCache interaction -- may slow the user interface.
                    mMeasurement = mManager.retrieve(summary.getId());

                    if(mManager.isBusy(mMeasurement)) {
                        // ignore measurements and audio currently in sync (e.g. measurement just finished)
                        Log.i(TAG, "measurement " + mMeasurement.getId().toString() + " is currently busy, skipping.");
                        advance();
                    } else if(mMeasurement.hasResult() && !mMeasurement.dirty) {
                        // skipping (only measurement itself, audio test comes next)
                        Log.i(TAG, "measurement " + mMeasurement.getId().toString() + " already has a result and is not dirty, skipping.");
                        next();
                    } else {
                        Log.i(TAG, "resending measurement " + mMeasurement.getId().toString() + " ...");

                        // send measurement again

                        mManager.setBusy(mMeasurement, true);
                        mManager.createAndPost(Endpoints.MEASUREMENT, mMeasurement, new MeasurementResultListener() {
                            @Override
                            public void onResult(Measurement measurement) {
                                mManager.setBusy(measurement, false);
                                next();
                            }

                            @Override
                            public void onError(Measurement measurement, ErrorCode code, String message) {
                                Log.e(TAG, "failed measurement " + measurement.getId() + " sync: " + code + " " + message);
                                mManager.setBusy(measurement, false);

                                /*
                                 * a connection error indicates a bad connection.
                                 * avoid further syncing on a bad connection.
                                 */
                                if (code != ErrorCode.CONNECTION_ERROR)
                                    next();
                                else
                                    finish();
                            }
                        });
                    }
                } else if(state == State.SEND_AUDIO) {
                    // load and send audio only if necessary
                    if(mMeasurement.series.audioSynced) {
                        advance();
                        mManager.removeFromCache(summary.getId());
                    } else {
                        byte[] wavData = mManager.retrieveAudio(mMeasurement.getId());
                        mMeasurement.series.setAudio(wavData, mMeasurement.series.audioStartTime);
                        // put audio again
                        mManager.setBusyAudio(mMeasurement, true);
                        mManager.putRemoteAudio(mMeasurement, new MeasurementResultListener() {
                            @Override
                            public void onResult(Measurement measurement) {
                                mManager.setBusyAudio(measurement, false);
                                advance();
                            }

                            @Override
                            public void onError(Measurement measurement, ErrorCode code, String message) {
                                Log.e(TAG, "failed audio sync of measurement " + measurement.getId() + ": " + code + " " + message);
                                mManager.setBusyAudio(measurement, false);
                                advance();
                            }
                        });
                    }
                }

            } catch (IOException e) {
                Log.e(TAG, "could not retrieve Measurement while walking MeasurementCache. remove " + summary.getId().toString() + " and continue.", e);
                try {
                    // happens for broken measurements - which are in cache, but there is no measurement json file
                    // may happen in unlucky crashes
                    mManager.removeFromCache(summary.getId());
                    // note: for empty JSON, we could remove the actual file as well (keep it neat)
                } catch (IOException e1) {
                    e1.printStackTrace();
                }
                advance();
            }
        }

        void advance() {
            mState = State.SEND_MEASUREMENT;
            mSynced++;

            dispatchProgress(mSynced, mList.size());

            if(++mI < mList.size())
                next();
            else
                finish();
        }

        void finish() {
            dispatchFinished(mSynced, mList.size());
            reset();
            mRun = false;
        }

        void reset() {
            mRun = false;
            mSynced = 0;
            mMeasurement = null;
            mList = null;
            mI = 0;
            mState = State.SEND_MEASUREMENT;
        }
    }

    static class StubListener implements MeasurementResultListener {
        @Override
        public void onResult(Measurement measurement) {
            onAny(measurement);
        }

        @Override
        public void onError(Measurement measurement, ErrorCode code, String message) {
            Log.e(TAG, "failed audio transmission: " + code + " " + message);
            onAny(measurement);
        }

        protected void onAny(Measurement measurement) {}
    }

    @Override
    public void create(final Measurement measurement, final MeasurementResultListener listener) {
        // to do: implement some nicer age-based LRU mDataCache (e.g. LinkedHashMap)
        if(mDataCache.size() > 3)
            mDataCache.clear();
        mDataCache.put(measurement.getId().toString(), measurement);

        postTask(new Runnable() {
            @Override
            public void run() {
                createAndPostAndPut(Endpoints.MEASUREMENT, measurement, listener);
            }
        });
    }

    @Override
    public void logFailure(final Measurement measurement, final MeasurementResultListener listener) {
        measurement.meta.failed = true;

        // to do: implement some nicer age-based LRU mDataCache (e.g. LinkedHashMap)
        if(mDataCache.size() > 3)
            mDataCache.clear();
        mDataCache.put(measurement.getId().toString(), measurement);

        postTask(new Runnable() {
            @Override
            public void run() {
                createAndPostAndPut(Endpoints.MEASUREMENT, measurement, listener);
            }
        });
    }

    private void createAndPostAndPut(String endpoint, final Measurement measurement, MeasurementResultListener listener) {
        /*
         * There are three sources calling createAndPost() to save and transmit measurements:
         *
         * * MeasureActivity - when finishing a measurement, and before switching to ResultActivity
         * * SyncTaskQueue - background sync
         * * ResultListActivity - when clicking a yet unsynced measurement
         */

        MeasurementResultListener wrapper = new UnbusyMRL(listener, this);

        setBusy(measurement, true);
        createAndPost(endpoint, measurement, wrapper);

        if(!measurement.series.audioSynced) {
            setBusyAudio(measurement, true);
            putRemoteAudio(measurement, new StubListener() {
                @Override
                protected void onAny(Measurement measurement) {
                    setBusyAudio(measurement, false);
                }
            });
        }
    }

    @Override
    public void email(final Measurement measurement, final MeasurementResultListener listener) {
        postTask(new Runnable() {
            @Override
            public void run() {
                createAndPost(Endpoints.EMAIL, measurement, listener);
            }
        });
    }

    static class UnbusyMRL implements MeasurementResultListener {
        private MeasurementResultListener listener;
        //private Measurement measurement;
        private IMeasurementDataManager manager;

        public UnbusyMRL(MeasurementResultListener listener, IMeasurementDataManager manager) {
            this.listener = listener;
            //this.measurement = measurement;
            this.manager = manager;
        }

        @Override
        public void onResult(Measurement measurement) {
            manager.setBusy(measurement, false);
            listener.onResult(measurement);
        }

        @Override
        public void onError(Measurement measurement, ErrorCode code, String message) {
            manager.setBusy(measurement, false);
            listener.onError(measurement, code, message);
        }
    }

    @Override
    public void createAndPost(String endpoint, final Measurement measurement, MeasurementResultListener listener) {
        // persistently mark dirty - from cache update until server response
        measurement.dirty = true;

        try {
            // add preliminary measurement (without results) to the cache. will be updated in writeLocal()
            if(mCache.indexOf(measurement.getId()) == -1)
                mCache.add(measurement);
            else
                mCache.update(measurement);
        } catch (IOException e) {
            listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
            return;
        }

        try {
            // send and classify
            putRemote(endpoint, measurement, listener);
        } catch (JSONException e) {
            listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
            // fall through to writeLocal()
        }

        try {
            writeLocal(measurement);
        } catch (IOException e) {
            listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
            return;
        }
    }

    public boolean removeFromCache(MeasurementId id) throws IOException {
        return mCache.remove(id);
    }

    @Override
    public Measurement retrieve(MeasurementId id) throws IOException {
        // to do: check if result is available, and if not, try to get it?
        // (how to avoid trying twice, if create() just failed before?)

        // TODO: caching to avoid loading several MB of JSON (slow when opening a measurement)

        if(mDataCache.containsKey(id.toString()))
            return mDataCache.get(id.toString());

        //Log.i(TAG, "readDataFileString(" + fileName(id) + ")");
        String json = mFileUtil.readDataFileString(fileName(id));
        Measurement measurement = Measurement.deserialize(json);

        // occasionally a glitch/crash may cause empty JSON file to be created
        if(measurement == null)
            throw new IOException("data file " + fileName(id) + " is empty");

        // to do: implement some nicer age-based LRU mDataCache (e.g. LinkedHashMap)
        if(mDataCache.size() > 3)
            mDataCache.clear();
        mDataCache.put(measurement.getId().toString(), measurement);

        return measurement;
    }

    @Override
    public byte[] retrieveAudio(MeasurementId id) throws IOException {
        return mFileUtil.readDataFileBytes(wavFileName(id));
    }

    @Override
    public void update(Measurement measurement, MeasurementResultListener listener) {
        // TODO implement me
        // don't forget mCache and mListener
        //throw new IllegalStateException("not implemented");

        create(measurement, listener);
    }

    /** list all measurements in ascending order (most recent last) */
    @Override
    public MeasurementSummary[] list() {
        return mCache.measurements;
    }

    @Override
    public void setSummaryListener(MeasurementSummaryListener listener) {
        mCache.setSummaryListener(listener);
    }

    private static final String JSON_SUFFIX = ".json";
    private static final String RESULT_PNG_SUFFIX = "-result.png";

    private static String fileName(MeasurementId id) {
        return id.toString() + JSON_SUFFIX;
    }
    private static String pngFileName(MeasurementId id) {
        return id.toString() + RESULT_PNG_SUFFIX;
    }
    private static String wavFileName(MeasurementId id) {
        return id.toString();
    }

    /** returns null if no report image */
    @Override
    public Bitmap getReportImage(MeasurementId id) {
        File imageFile = new File(mFileUtil.getStorageDirectory().getAbsolutePath() + FileUtil.PERSISTENT_DATA_DIR, pngFileName(id));
        if(imageFile.exists())
            return BitmapFactory.decodeFile(imageFile.getAbsolutePath());
        else
            return null;
    }

    private void writeLocal(Measurement measurement) throws IOException {
        // we write three times: after measurement, when getting result, and when having posted audio.
        // this is a waste, and we could potentially skip/cancel writes, if results arrive earlier.
        // TODO: implement a 'dirty' flag with a timeout, and only write once, if possible
        synchronized (measurement) {
            String json = measurement.serialize(JsonUtil.Target.LOCAL);
            mFileUtil.writeDataFileContents(fileName(measurement.getId()), json);
        }

        // avoid writing the WAV itself several times - skip if it exists already.
        String wavFile = wavFileName(measurement.getId());
        if(mFileUtil.dataFileExists(wavFile))
            return;

        byte[] wavData = measurement.series.getAudio();
        if(wavData != null)
            mFileUtil.writeDataFileContents(wavFile, wavData);
    }

    private static final int REQUEST_INITIAL_TIMEOUT_MS = 10000;
    private static final int PING_TIMEOUT_MS = 3000;

    private static final String API_URL_PREFIX_PRODUCTION = "https://mlapi.heartshield.net";
    private static final String API_URL_PREFIX_TEST = "http://192.168.1.82:8000";

    private String getApiUrlPrefix() {
        if(mUseTestServer)
            return API_URL_PREFIX_TEST;
        else
            return API_URL_PREFIX_PRODUCTION;
    }

    /** API endpoints of server */
    private static class Endpoints {
        static final String MEASUREMENT = "/v3/measurement";
        static final String EMAIL = "/v3/email";
        static final String AUDIO = "/v21/audio_data";
        static final String PING = "/v3/ping";
        static final String ACTIVATE = "/v3/activate";
        static final String LOG_MESSAGE = "/v3/log_message";
    }

    @Override
    public void ping(String appId, final PingResultListener listener) {
        String url = getApiUrlPrefix() + Endpoints.PING;

        JSONObject requestData = new JSONObject();
        try {
            requestData.put("appId", appId);
        } catch(JSONException e) {
            return;
        }

        JsonObjectRequest request = new JsonObjectRequest
                (Request.Method.POST, url, requestData, new Response.Listener<JSONObject>() {
                    @Override
                    public void onResponse(JSONObject response) {
                        try {
                            String status = response.getString("status");
                            if(status.equals("ok"))
                                listener.onResult();
                        } catch(JsonSyntaxException e) {
                            e.printStackTrace();
                            listener.onError();
                        } catch (JSONException e) {
                            e.printStackTrace();
                            listener.onError();
                        }
                    }
                }, new Response.ErrorListener() {
                    @Override
                    public void onErrorResponse(VolleyError error) {
                        listener.onError();
                    }
                });
        request.setRetryPolicy(new DefaultRetryPolicy(
                PING_TIMEOUT_MS,
                DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
                DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
        mRequestQueue.add(request);
    }

    @Override
    public void activate(String activationCode, final ActivationResultListener listener) {
        String url = getApiUrlPrefix() + Endpoints.ACTIVATE;

        JSONObject requestData = new JSONObject();
        try {
            //requestData.put("appId", appId); // this is temporary, really. (the random ID which will be replaced)
            requestData.put("activationCode", activationCode);
        } catch(JSONException e) {
            return;
        }

        Log.i(TAG, "checkActivation(activationCode=" + activationCode + ") -> " + url);
        JsonObjectRequest request = new JsonObjectRequest
                (Request.Method.POST, url, requestData, new Response.Listener<JSONObject>() {
                    @Override
                    public void onResponse(JSONObject response) {
                        try {
                            String status = response.getString("status");
                            if(status.equals("ok"))
                                listener.onResult(response.getString("appId"));
                            else
                                listener.onError(response.getString("reason"));
                        } catch(JsonSyntaxException e) {
                            e.printStackTrace();
                            listener.onError("local error");
                        } catch (JSONException e) {
                            e.printStackTrace();
                            listener.onError("local error");
                        }
                    }
                }, new Response.ErrorListener() {
                    @Override
                    public void onErrorResponse(VolleyError error) {
                        listener.onError("connection error");
                    }
                });
        request.setRetryPolicy(new DefaultRetryPolicy(
                PING_TIMEOUT_MS,
                DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
                DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
        mRequestQueue.add(request);
    }


    @Override
    public void logMessage(String appId, String mtype, String message) {
        String url = getApiUrlPrefix() + Endpoints.LOG_MESSAGE;

        JSONObject requestData = new JSONObject();
        try {
            requestData.put("appId", appId);
            requestData.put("mtype", mtype);
            requestData.put("message", message);
        } catch(JSONException e) {
            return;
        }

        JsonObjectRequest request = new JsonObjectRequest
                (Request.Method.POST, url, requestData, new Response.Listener<JSONObject>() {
                    @Override
                    public void onResponse(JSONObject response) {
                    }
                }, new Response.ErrorListener() {
                    @Override
                    public void onErrorResponse(VolleyError error) {
                    }
                });
        request.setRetryPolicy(new DefaultRetryPolicy(
                PING_TIMEOUT_MS,
                DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
                DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
        mRequestQueue.add(request);
    }

    private void putRemote(String endpoint, final Measurement measurement, final MeasurementResultListener listener) throws JSONException {
        String url = getApiUrlPrefix() + endpoint;

        JSONObject requestData = new JSONObject(measurement.serialize(JsonUtil.Target.REMOTE));

        JsonObjectRequest request = new JsonObjectRequest
                (Request.Method.POST, url, requestData, new Response.Listener<JSONObject>() {
                    @Override
                    public void onResponse(JSONObject response) {
                        Log.d(TAG, "response: " + response.toString());
                        try {
                            measurement.result = MeasurementResult.deserialize(response.toString());
                            measurement.dirty = false;

                            Runnable writer = new Runnable() {
                                @Override
                                public void run() {
                                    try {
                                        writeLocal(measurement); // update result
                                    } catch (IOException e) {
                                        listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
                                    }
                                }
                            };

                            postTask(writer);

                            if(response.has("report_image")) {
                                String b64image = response.getString("report_image");
                                byte[] pngImage = Base64.decode(b64image, Base64.DEFAULT);
                                mFileUtil.writeDataFileContents(pngFileName(measurement.getId()), pngImage);
                            }

                            mCache.update(measurement);

                            listener.onResult(measurement);
                        } catch(JsonSyntaxException e) {
                            listener.onError(measurement, ErrorCode.PROTOCOL_ERROR, e.getMessage());
                        } catch (IOException e) {
                            listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
                        } catch (JSONException e) {
                            listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
                        }
                    }
                }, new Response.ErrorListener() {
                    @Override
                    public void onErrorResponse(VolleyError error) {
                        dispatchOnError(listener, measurement, error);
                    }
                });
        request.setRetryPolicy(new DefaultRetryPolicy(
                REQUEST_INITIAL_TIMEOUT_MS,
                DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
                DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
        mRequestQueue.add(request);
    }

    private void postTask(Runnable runnable) {
        // if we have a background thread running, post the write task in the background
        if(mBackgroundThread != null)
            mBackgroundHandler.post(runnable);
        else
            runnable.run();
    }

    @Override
    public void putRemoteAudio(final Measurement measurement, final MeasurementResultListener listener) {
        String url = getApiUrlPrefix() + Endpoints.AUDIO + "/" + wavFileName(measurement.getId());

        Log.i(TAG, "putRemoteAudio() to url = " + url);

        final byte[] body = measurement.series.getAudio();

        StringRequest request = new StringRequest
                (Request.Method.PUT, url, new Response.Listener<String>() {
                    @Override
                    public void onResponse(String response) {
                        Log.d(TAG, "audio PUT successful. response: " + response);

                        measurement.series.audioSynced = true;
                        try {
                            writeLocal(measurement); // update audioSynced
                            mCache.update(measurement);
                            listener.onResult(measurement);
                        } catch (IOException e) {
                            listener.onError(measurement, ErrorCode.LOCAL_ERROR, e.getMessage());
                        }
                    }
                }, new Response.ErrorListener() {
                    @Override
                    public void onErrorResponse(VolleyError error) {
                        dispatchOnError(listener, measurement, error);
                    }
                })
        {
            @Override
            public byte[] getBody() { return body; }

            @Override
            public String getBodyContentType() { return "audio/wav"; }
        };

        request.setRetryPolicy(new DefaultRetryPolicy(
                REQUEST_INITIAL_TIMEOUT_MS,
                DefaultRetryPolicy.DEFAULT_MAX_RETRIES,
                DefaultRetryPolicy.DEFAULT_BACKOFF_MULT));
        mRequestQueue.add(request);
    }

    /** decode and dispatch Volley error to MeasurementResultListener */
    private static void dispatchOnError(MeasurementResultListener listener, Measurement measurement, VolleyError error) {
        ErrorCode code;
        String body = "";

        String statusCode = "";
        if(error.networkResponse != null)
            statusCode = String.valueOf(error.networkResponse.statusCode);

        if(error.networkResponse != null && error.networkResponse.data != null) {
            try {
                body = new String(error.networkResponse.data, "UTF-8");
                if(!statusCode.equals(""))
                    body = "HTTP " + statusCode + ": " + body;
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }

        if(error instanceof NetworkError || error instanceof TimeoutError) {
            code = ErrorCode.CONNECTION_ERROR;
        } else if(error instanceof ServerError) {
            code = ErrorCode.HTTP_ERROR;
        } else {
            code = ErrorCode.UNKNOWN_ERROR;
        }

        if(body.equals("") && error.getMessage() != null) {
            body = error.getMessage();
        }

        listener.onError(measurement, code, body);
    }

    @Override
    public void setUseTestServer(boolean useTestServer) {
        mUseTestServer = useTestServer;
    }

    @Override
    public boolean isUnderTest() {
        return mUseTestServer;
    }

    public static MeasurementDataManager getInstance(Context context) {
        if(sInstance == null) {
            sInstance = new MeasurementDataManager(context);
        }
        return sInstance;
    }
}
