/*
 * Copyright (c) 2017. Mathias Ciliberto, Francisco Javier OrdoƱez Morales,
 * Hristijan Gjoreski, Daniel Roggen
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy of
 * this software and associated documentation files (the "Software"), to deal in
 * the Software without restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
 * Software, and to permit persons to whom the Software is furnished to do so,
 * subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
 * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */

package uk.ac.sussex.wear.android.datalogger.bt;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothServerSocket;
import android.bluetooth.BluetoothSocket;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.ParcelUuid;
import android.os.SystemClock;
import android.util.Log;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.UUID;

import uk.ac.sussex.wear.android.datalogger.Constants;
import uk.ac.sussex.wear.android.datalogger.R;
import uk.ac.sussex.wear.android.datalogger.SharedPreferencesHelper;
import uk.ac.sussex.wear.android.datalogger.data.CommandBase;
import uk.ac.sussex.wear.android.datalogger.data.CommandKA;

public class BluetoothConnectionHelper {

    private static final String TAG = BluetoothConnectionHelper.class.getSimpleName();

    private static final String NAME = "BluetoothConnectionHelper";

    // Member fields
    private final BluetoothAdapter mBluetoothAdapter;
    private final Handler mHandler;
    private final Context mContext;
    private AcceptThread[] mAcceptThreads = null;
    private ConnectThread mConnectThread = null;
    private ConnectedThread[] mConnectedThreads = null;
    private KeepAlive[] mKeepAlives = null;
    private int[] mStates;
    private boolean mIsServer;
    private Runnable keepAliveRunnable = null;

    // Constants that indicate the current connection state
    public static final int STATE_NONE = 0;       // we're doing nothing
    public static final int STATE_LISTEN = 1;     // now listening for incoming connections
    public static final int STATE_CONNECTING = 2; // now initiating an outgoing connection
    public static final int STATE_CONNECTED = 3;  // now connected to a remote device

    // Unique UUIDs for this application. Randomly generated
    private static final String[] mUUIDs  = {
            "70a7c5d0-9aa6-11e6-9f33-a24fc0d9649c",
            "70a7c832-9aa6-11e6-9f33-a24fc0d9649c",
            "70a7c972-9aa6-11e6-9f33-a24fc0d9649c"
    };


    public BluetoothConnectionHelper(Context context, Handler handler, boolean isServer) {
        mContext = context;
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        mHandler = handler;
        mIsServer = isServer;
        mStates = new int[]{STATE_NONE, STATE_NONE, STATE_NONE};
        mConnectedThreads = new ConnectedThread[]{null, null, null};
        mKeepAlives = new KeepAlive[]{null, null, null};
        if (isServer) {
            mAcceptThreads = new AcceptThread[]{null, null, null};
        }
    }

    /**
     * Start the chat service. Specifically start AcceptThread to begin a
     * session in listening (server) mode. Called by the Activity onResume()
     */
    public synchronized void start(int index) {
        Log.d(TAG, "::start Starting bluetooth at index " + index);

        if (keepAliveRunnable != null) {
            mHandler.removeCallbacks(keepAliveRunnable);
            keepAliveRunnable = null;
        }
        keepAliveRunnable = new KeepAliveRunnable(index);

        // Cancel any thread attempting to make a connection
        if (mConnectThread != null) {
            mConnectThread.cancel();
            mConnectThread = null;
        }

        // Cancel any thread currently running a connection
        if (mConnectedThreads[index] != null) {
            mConnectedThreads[index].cancel();
            mConnectedThreads[index] = null;
        }

        if (mIsServer) {
            setState(STATE_LISTEN, index);

            if (mKeepAlives[index] != null) {
                mKeepAlives[index].cancel();
                mKeepAlives[index] = null;
            }

            // Start the thread to listen on a BluetoothServerSocket
            if (mAcceptThreads[index] == null) {
                mAcceptThreads[index] = new AcceptThread(index);
                mAcceptThreads[index].start();
            }

//            try{
//                Method getUuidsMethod = BluetoothAdapter.class.getDeclaredMethod("getUuids", null);
//                Log.i(TAG, "::start Supported features (UUIDs) of the local device are:");
//                for (ParcelUuid uuid : (ParcelUuid[]) getUuidsMethod.invoke(mBluetoothAdapter, null)){
//                    Log.i(TAG, "::start "+ uuid.toString() + ". Is socket uuid: " + mUUIDs[index].equals(uuid.toString()));
//                }
//            } catch (Exception e){
//                Log.e(TAG,"::start ");
//            }

        }

    }

    /**
     * Stop all threads
     */
    public synchronized void stop(int index) {
        Log.d(TAG, "::stop Stopping bluetooth slot "+index);

        if (keepAliveRunnable != null) {
            mHandler.removeCallbacks(keepAliveRunnable);
            keepAliveRunnable = null;
        }

        if (mConnectThread != null) {
            mConnectThread.cancel();
            mConnectThread = null;
        }

        if (mConnectedThreads[index] != null) {
            mConnectedThreads[index].cancel();
            mConnectedThreads[index] = null;
        }

        if (mIsServer) {
            if (mAcceptThreads[index] != null) {
                mAcceptThreads[index].cancel();
                mAcceptThreads[index] = null;
            }

            if (mKeepAlives[index] != null) {
                mKeepAlives[index].cancel();
                mKeepAlives[index] = null;
            }
        }

        setState(STATE_NONE, index);
    }

    /**
     * Set the current state of the chat connection
     *
     * @param state An integer defining the current connection state
     */
    private synchronized void setState(int state, int index) {
        Log.d(TAG, "setState("+index+") " + mStates[index] + " -> " + state);
        mStates[index] = state;

        // Give the new state to the Handler so the main Service is aware
        mHandler.obtainMessage(Constants.BLUETOOTH_MESSAGE_STATE_CHANGE, state, index).sendToTarget();
    }

    /**
     * Return the current connection state.
     */
    public synchronized int getState(int index) {
        return mStates[index];
    }

    public synchronized  boolean isConnected(int index) {
        if (mConnectedThreads[index] == null)
            return false;

        return mConnectedThreads[index].isConnected();
    }


    /**
     * Start the ConnectedThread to begin managing a Bluetooth connection
     *
     * @param socket The BluetoothSocket on which the connection was made
     * @param device The BluetoothDevice that has been connected
     */
    public synchronized void connected(BluetoothSocket socket, BluetoothDevice device, int index) {
        Log.d(TAG, "::connected Starting ConnectedThread for bluetooth at index "+index);

        // Cancel the thread that completed the connection
        if (mConnectThread != null) {
            mConnectThread.cancel();
            mConnectThread = null;
        }

        // Cancel any thread currently running a connection
        if (mConnectedThreads[index] != null) {
            mConnectedThreads[index].cancel();
            mConnectedThreads[index] = null;
        }

        if (mIsServer) {
            if (mAcceptThreads[index] != null) {
                mAcceptThreads[index].cancel();
                mAcceptThreads[index] = null;
            }

            if (mKeepAlives[index] != null) {
                mKeepAlives[index].cancel();
                mKeepAlives[index] = null;
            }
        }

        // Start the thread to manage the connection and perform transmissions
        mConnectedThreads[index] = new ConnectedThread(socket, index);
        mConnectedThreads[index].start();

        // If the keep alive mechanism is active
        if (SharedPreferencesHelper.isEnabledKeepalive(mContext)) {
            if (mIsServer) { // the master creates a thread to send KA messages at regular intervals
                mKeepAlives[index] = new KeepAlive(index);
                mKeepAlives[index].start();
            } else { // slaves post delayed runnables in the main looper to check for KA messages
                int slaveKA = SharedPreferencesHelper.getSlaveKeepaliveInterval(mContext);
                Log.d(TAG, "::connected Posting keepalive runnable with delay " + slaveKA + " millis at index "+ index);
                mHandler.postDelayed(keepAliveRunnable, slaveKA);
            }
        }

        // Send the name of the connected device back to the UI Activity
        Message msg = mHandler.obtainMessage(Constants.BLUETOOTH_MESSAGE_DEVICE_ADDRESS);
        Bundle bundle = new Bundle();
        bundle.putString(Constants.BLUETOOTH_CONNECTED_DEVICE_ADDRESS, device.getAddress());
        bundle.putInt(Constants.BLUETOOTH_CONNECTED_DEVICE_LOCATION, index);
        msg.setData(bundle);
        mHandler.sendMessage(msg);

        setState(STATE_CONNECTED, index);
    }

    /**
     * Indicate that the connection attempt failed and notify the UI Activity.
     */
    private synchronized void connectionFailed(int index) {
        // Send a failure message back to the Activity
        Message msg = mHandler.obtainMessage(Constants.BLUETOOTH_MESSAGE_CONNECTION_FAILED, index);
        Bundle bundle = new Bundle();
        bundle.putInt(Constants.BLUETOOTH_CONNECTED_DEVICE_LOCATION, index);
        msg.setData(bundle);
        mHandler.sendMessage(msg);
    }

    /**
     * Indicate that the connection was lost and notify the UI Activity.
     */
    private synchronized void connectionLost(int index) {
        // Send the name of the connected device back to the UI Activity
        Message msg = mHandler.obtainMessage(Constants.BLUETOOTH_MESSAGE_CONNECTION_LOST);
        Bundle bundle = new Bundle();
        bundle.putInt(Constants.BLUETOOTH_CONNECTED_DEVICE_LOCATION, index);
        msg.setData(bundle);
        mHandler.sendMessage(msg);
    }

    /**
     * Start the ConnectThread to initiate a connection to a remote device.
     *
     * @param address The address to connect
     */
    public synchronized void connect(String address, int index) {
        Log.i(TAG, "::connect Trying to connect to address " + address + ". ");

        // Cancel any thread attempting to make a connection
        if (mStates[index] == STATE_CONNECTING) {
            if (mConnectThread != null) {
                mConnectThread.cancel();
                mConnectThread = null;
            }
        }

        // Cancel any thread currently running a connection
        if (mConnectedThreads[index] != null) {
            mConnectedThreads[index].cancel();
            mConnectedThreads[index] = null;
        }

        // Get the BluetoothDevice object
        BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(address);
        switch (device.getBondState()){
            case BluetoothDevice.BOND_NONE:
                Log.i(TAG, "The remote device is not bonded (paired)");
                break;
            case BluetoothDevice.BOND_BONDING:
                Log.i(TAG, "Bonding (pairing) is in progress with the remote device");
                break;
            case BluetoothDevice.BOND_BONDED:
                Log.i(TAG, "The remote device is bonded (paired)");
                boolean remoteUuid = false;
                for (ParcelUuid uuid : device.getUuids()){
                    if (mUUIDs[index].equals(uuid.toString())) {
                        remoteUuid = true;
                    }
                    Log.i(TAG, "::connect "+ uuid.toString() + ". Is target uuid: " + mUUIDs[index].equals(uuid.toString()));
                }
                if (!remoteUuid) {
                    Log.e(TAG, "::connect Service UUID (" + mUUIDs[index] + ") not supported in remote device.");
                }
                break;
        }

        // Start the thread to connect with the given device
        mConnectThread = new ConnectThread(device, index);
        mConnectThread.start();
        setState(STATE_CONNECTING, index);

    }

    public void broadcastMessage(String message){

        for (int i=0; i<mUUIDs.length; i++){
            sendMessage(message, i);
        }
    }


    public synchronized void sendMessage(String message, int index){
        // Check that we're actually connected before trying anything
        Log.d(TAG,"Sending message "+message+" to "+index);
        if (getState(index) != STATE_CONNECTED) {
            return;
        }

        // Check that there's actually something to send
        if (message.length() > 0) {
            // Get the message bytes and tell the BluetoothChatService to write
            byte[] send = message.getBytes();
            write(send, index);
        }
    }


    /**
     * Write to the ConnectedThread in an unsynchronized manner
     *
     * @param out The bytes to write
     * @see ConnectedThread#write(byte[])
     */
    private void write(byte[] out, int index) {
        Log.d(TAG,"Writing message to "+index);
        // Create temporary object
        ConnectedThread r;
        // Synchronize a copy of the ConnectedThread
        synchronized (this) {
            if (getState(index) != STATE_CONNECTED) return;
            Log.d(TAG,"Getting a synchronized copy of the ConnectedThread at "+index);
            r = mConnectedThreads[index];
        }
        // Perform the write unsynchronized
        r.write(out);
    }


    /**
     * This thread runs while listening for incoming connections. It behaves
     * like a server-side client. It runs until a connection is accepted
     * (or until cancelled).
     */
    private class AcceptThread extends Thread {
        // The local server socket
        private final BluetoothServerSocket mmServerSocket;
        private final int mIndex;

        public AcceptThread(int index) {
            mIndex = index;
            BluetoothServerSocket tmp = null;

            // Create a new listening server socket
            try {
                tmp = mBluetoothAdapter.listenUsingRfcommWithServiceRecord(
                        NAME,
                        UUID.fromString(mUUIDs[mIndex]));
            } catch (IOException e) {
                Log.e(TAG, "::AcceptThread Socket listen() at "+ mIndex + " failed.", e);
            }
            mmServerSocket = tmp;
        }

        public void run() {
//            Log.d(TAG, "BEGIN mAcceptThread" + this);
            setName("AcceptThread"+mIndex);

            BluetoothSocket socket;

            // Listen to the server socket if we're not connected
            while (mStates[mIndex] != STATE_CONNECTED) {
                try {
                    // This is a blocking call and will only return on a
                    // successful connection or an exception
                    socket = mmServerSocket.accept();
                } catch (IOException e) {
                    Log.e(TAG, "::AcceptThread Socket accept() at index "+ mIndex +" failed.", e);
                    break;
                }

                // If a connection was accepted
                if (socket != null) {
                    synchronized (BluetoothConnectionHelper.this) {
                        switch (mStates[mIndex]) {
                            case STATE_LISTEN:
                            case STATE_CONNECTING:
                                // Situation normal. Start the connected thread.
                                connected(socket, socket.getRemoteDevice(), mIndex);
                                break;
                            case STATE_NONE:
                            case STATE_CONNECTED:
                                // Either not ready or already connected. Terminate new socket.
                                try {
                                    socket.close();
                                } catch (IOException e) {
                                    Log.e(TAG, "::AcceptThread Could not close unwanted socket at index " + mIndex, e);
                                }
                                break;
                        }
                    }
                }
            }
//            Log.i(TAG, "END mAcceptThread");

        }

        public void cancel() {
            Log.d(TAG, "::AcceptThread Socket cancel at index " + mIndex);
            try {
                mmServerSocket.close();
            } catch (IOException e) {
                Log.e(TAG, "::AcceptThread Socket close() of server failed at index " + mIndex, e);
            }
        }
    }


    /**
     * This thread runs while attempting to make an outgoing connection
     * with a device. It runs straight through; the connection either
     * succeeds or fails.
     */
    private class ConnectThread extends Thread {
        private final BluetoothSocket mmSocket;
        private final BluetoothDevice mmDevice;
        private final int mIndex;

        public ConnectThread(BluetoothDevice device, int index) {
            mmDevice = device;
            mIndex = index;
            BluetoothSocket tmp = null;

            // Get a BluetoothSocket for a connection with the
            // given BluetoothDevice

            Log.i(TAG, "::ConnectThread Get a BluetoothSocket for a connection at index " + mIndex);
            try {
                tmp = device.createRfcommSocketToServiceRecord(UUID.fromString(mUUIDs[mIndex]));
            } catch (IOException e) {
                Log.e(TAG, "::ConnectThread Socket create() failed at index "+mIndex, e);
            }
            mmSocket = tmp;
        }

        public void run() {
//            Log.i(TAG, "BEGIN mConnectThread");
            setName("ConnectThread"+mIndex);

            // Always cancel discovery because it will slow down a connection
            mBluetoothAdapter.cancelDiscovery();

            // Make a connection to the BluetoothSocket
            try {
                // This is a blocking call and will only return on a
                // successful connection or an exception
                Log.d(TAG,"::ConnectThread Creating a connection to a BluetoothSocket in index "+mIndex);
                mmSocket.connect();
            } catch (IOException e) {
                try {
                    mmSocket.close();
                } catch (IOException e2) {
                    Log.e(TAG, "::ConnectThread  Unable to close() socket during connection failure for index "+mIndex, e2);
                }
                connectionFailed(mIndex);
                return;
            }

            // Reset the ConnectThread because we're done
            synchronized (BluetoothConnectionHelper.this) {
                mConnectThread = null;
            }

            // Start the connected thread
            connected(mmSocket, mmDevice, mIndex);
        }

        public void cancel() {
            try {
                mmSocket.close();
            } catch (IOException e) {
                Log.e(TAG, "close() of connect socket failed", e);
            }
        }
    }


    private class KeepAlive extends Thread {

        private int mIndex;
        private boolean isRunning;

        public KeepAlive(int index) {
            mIndex = index;
            isRunning = true;
        }

        private String generateKeepAliveCommand(){
            boolean dataCollectionState = SharedPreferencesHelper.getDataCollectionState(mContext);
            String sessionId ="";
            long nanos = -1;
            if (dataCollectionState){
                sessionId = SharedPreferencesHelper.getDataCollectionSessionObject(mContext).getSessionId();
                nanos = SystemClock.elapsedRealtimeNanos();
            }

            boolean labelsAnnotationState = SharedPreferencesHelper.getLabelsAnnotationState(mContext);
            int activityLabel = -1;
            int bodyPositionLabel = -1;
            int locationLabel = R.id.ui_iolocation_radioButton_outside;
            if (labelsAnnotationState){
                activityLabel = SharedPreferencesHelper.getAnnotatedActivityLabel(mContext);
                bodyPositionLabel = SharedPreferencesHelper.getAnnotatedBodyPositionLabel(mContext);
                locationLabel = SharedPreferencesHelper.getAnnotatedLocationLabel(mContext);
            }

            return new CommandKA(dataCollectionState, sessionId,  nanos,
                    labelsAnnotationState, activityLabel, bodyPositionLabel, locationLabel)
                    .getMessageBluetooth();
        }

        public synchronized void run() {
            while (isRunning){

                Log.d(TAG,"KeepAlive::run Sending keep alive in slot "+mIndex);
                BluetoothConnectionHelper.this.sendMessage(generateKeepAliveCommand(), mIndex);

                try {
                    int masterKA = SharedPreferencesHelper.getMasterKeepaliveInterval(mContext);
                    wait(masterKA);
                } catch (InterruptedException e) {
                    Log.e(TAG, "Error in keepalive thread for slot" + mIndex);
                }

            }
        }

        public void cancel() {
            isRunning = false;
        }
    }

    /**
     * This thread runs during a connection with a remote device.
     * It handles all incoming and outgoing transmissions.
     */
    private class ConnectedThread extends Thread {
        private final BluetoothSocket mmSocket;
        private final InputStream mmInStream;
        private final OutputStream mmOutStream;
        private final int mIndex;

        public ConnectedThread(BluetoothSocket socket, int index) {
//            Log.d(TAG, "create ConnectedThread");
            mmSocket = socket;
            mIndex = index;
            InputStream tmpIn = null;
            OutputStream tmpOut = null;

            // Get the BluetoothSocket input and output streams
            try {
                tmpIn = socket.getInputStream();
                tmpOut = socket.getOutputStream();
            } catch (IOException e) {
                Log.e(TAG, "::ConnectedThread Input/Output stream sockets not created", e);
            }

            mmInStream = tmpIn;
            mmOutStream = tmpOut;
        }

        public void run() {
            byte[] buffer = new byte[CommandBase.MAX_LENGTH];
            int bytes;

            // Keep listening to the InputStream while connected
            while (mStates[mIndex] == STATE_CONNECTED) {
                try {
                    // Read from the InputStream
                    bytes = mmInStream.read(buffer);

                    // The obtained bytes are transformed into commands
                    ArrayList<String> list =  CommandBase.parseMessage(new String(buffer, 0, bytes));
                    if (SharedPreferencesHelper.isEnabledKeepalive(mContext)
                            && CommandBase.containsCommand(list, CommandBase.COMMAND_KEEP_ALIVE_EVENT)) {
                        mHandler.removeCallbacks(keepAliveRunnable);
                        int slaveKA = SharedPreferencesHelper.getSlaveKeepaliveInterval(mContext);
                        mHandler.postDelayed(keepAliveRunnable, slaveKA);
                    }

                    if (CommandBase.containsCommand(list, CommandBase.COMMAND_KEEP_ALIVE_EVENT)) {
                        assert !mIsServer;
                        mHandler.removeCallbacks(keepAliveRunnable);
                        int slaveKA = SharedPreferencesHelper.getSlaveKeepaliveInterval(mContext);
                        mHandler.postDelayed(keepAliveRunnable, slaveKA);
                    }

                    // Send the obtained commands to the service
                    Message msg = mHandler.obtainMessage(Constants.BLUETOOTH_MESSAGE_READ);
                    Bundle bundle = new Bundle();
                    bundle.putStringArrayList(Constants.BLUETOOTH_MESSAGE_READ_COMMANDS, list);
                    msg.setData(bundle);
                    mHandler.sendMessage(msg);

                } catch (IOException e) {
                    Log.e(TAG, "::ConnectedThread Disconnected in "+mIndex, e);
                    connectionLost(mIndex);
                    // Start the service over to restart
//                    BluetoothConnectionHelper.this.start(mIndex);
                    break;
                }
            }
        }

        public boolean isConnected() {
            return mmSocket.isConnected();
        }

        /**
         * Write to the connected OutStream.
         *
         * @param buffer The bytes to write
         */
        public void write(byte[] buffer) {
            try {

                if (mmSocket.isConnected()) {
                    mmOutStream.write(buffer);
//                    mHandler.obtainMessage(Constants.BLUETOOTH_MESSAGE_WRITE, -1, -1, buffer)
//                            .sendToTarget();
                } else {
                    connectionLost(mIndex);
                }
            } catch (IOException e) {
                Log.e(TAG, "Exception during write", e);
                connectionLost(mIndex);
            }
        }

        public void cancel() {
            try {
                mmSocket.close();
            } catch (IOException e) {
                Log.e(TAG, "close() of connect socket failed", e);
            }
        }
    }

    class KeepAliveRunnable implements Runnable {
        int mIndex;

        KeepAliveRunnable(int index) {
            mIndex = index;
        }

        @Override
        public void run() {
            Log.i(TAG, "Keep alive timeout. Disconnecting socket at position: " + mIndex);
            mHandler.removeCallbacks(keepAliveRunnable);
            connectionLost(mIndex);
        }
    }

}