// Copyright 2016 Franco Bugnano
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//	http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cordova.plugin.networking.bluetooth;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaArgs;
import org.apache.cordova.CordovaInterface;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CordovaWebView;
import org.apache.cordova.PluginResult;

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

import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothServerSocket;
import android.bluetooth.BluetoothSocket;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.Manifest;
import android.os.ParcelUuid;
import android.util.Log;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;

public class NetworkingBluetooth extends CordovaPlugin {
	public static final String TAG = "CordovaNetworkingBluetooth";
	public static final String SERVICE_NAME = "CordovaNetworkingBluetooth";
	public static final int REQUEST_ENABLE_BT = 1773;
	public static final int REQUEST_DISCOVERABLE_BT = 1885;
	public static final int START_DISCOVERY_REQ_CODE = 1997;
	public static final int READ_BUFFER_SIZE = 4096;

	public class SocketSendData {
		public CallbackContext mCallbackContext;
		public BluetoothSocket mSocket;
		public byte[] mData;

		public SocketSendData(CallbackContext callbackContext, BluetoothSocket socket, byte[] data) {
			this.mCallbackContext = callbackContext;
			this.mSocket = socket;
			this.mData = data;
		}
	}

	public BluetoothAdapter mBluetoothAdapter = null;
	public ConcurrentHashMap<Integer, CallbackContext> mContextForActivity = new ConcurrentHashMap<Integer, CallbackContext>();
	public ConcurrentHashMap<Integer, CallbackContext> mContextForPermission = new ConcurrentHashMap<Integer, CallbackContext>();
	public CallbackContext mContextForAdapterStateChanged = null;
	public CallbackContext mContextForDeviceAdded = null;
	public CallbackContext mContextForReceive = null;
	public CallbackContext mContextForReceiveError = null;
	public CallbackContext mContextForAccept = null;
	public CallbackContext mContextForAcceptError = null;
	public CallbackContext mContextForEnable = null;
	public CallbackContext mContextForDisable = null;
	public boolean mDeviceAddedRegistered = false;
	public int mPreviousScanMode = BluetoothAdapter.SCAN_MODE_NONE;
	public AtomicInteger mSocketId = new AtomicInteger(1);
	public ConcurrentHashMap<Integer, BluetoothSocket> mClientSockets = new ConcurrentHashMap<Integer, BluetoothSocket>();
	public ConcurrentHashMap<Integer, BluetoothServerSocket> mServerSockets = new ConcurrentHashMap<Integer, BluetoothServerSocket>();
	public LinkedBlockingQueue<SocketSendData> mSendQueue = new LinkedBlockingQueue<SocketSendData>();

	@Override
	public void initialize(CordovaInterface cordova, CordovaWebView webView) {
		super.initialize(cordova, webView);

		this.mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();

		if (this.mBluetoothAdapter != null) {
			this.mPreviousScanMode = this.mBluetoothAdapter.getScanMode();
		}

		cordova.getThreadPool().execute(new Runnable() {
			public void run() {
				writeLoop();
			}
		});
	}

	@Override
	public boolean execute(String action, CordovaArgs args, final CallbackContext callbackContext) throws JSONException {
		IntentFilter filter;

		if (this.mBluetoothAdapter == null) {
			callbackContext.error("Device does not support Bluetooth");
			return false;
		}

		if (action.equals("registerAdapterStateChanged")) {
			this.mContextForAdapterStateChanged = callbackContext;

			filter = new IntentFilter();
			filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
			filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED);
			filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
			filter.addAction(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED);
			cordova.getActivity().registerReceiver(this.mReceiver, filter);

			return true;
		} else if (action.equals("registerDeviceAdded")) {
			this.mContextForDeviceAdded = callbackContext;
			return true;
		} else if (action.equals("registerReceive")) {
			this.mContextForReceive = callbackContext;
			return true;
		} else if (action.equals("registerReceiveError")) {
			this.mContextForReceiveError = callbackContext;
			return true;
		} else if (action.equals("registerAccept")) {
			this.mContextForAccept = callbackContext;
			return true;
		} else if (action.equals("registerAcceptError")) {
			this.mContextForAcceptError = callbackContext;
			return true;
		} else if (action.equals("getAdapterState")) {
			this.getAdapterState(callbackContext, false);
			return true;
		} else if (action.equals("requestEnable")) {
			if (!this.mBluetoothAdapter.isEnabled()) {
				Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
				this.prepareActivity(action, args, callbackContext, enableBtIntent, REQUEST_ENABLE_BT);
			} else {
				callbackContext.success();
			}
			return true;
		} else if (action.equals("enable")) {
			// If there already is another enable action pending, call the error callback in order
			// to notify that the previous action has been cancelled
			if (this.mContextForEnable != null) {
				this.mContextForEnable.error(1);
				this.mContextForEnable = null;
			}

			if (!this.mBluetoothAdapter.isEnabled()) {
				if (!this.mBluetoothAdapter.enable()) {
					callbackContext.error(0);
				} else {
					// Save the context, in order to send the result once the action has been completed
					this.mContextForEnable = callbackContext;
				}
			} else {
				callbackContext.success();
			}
			return true;
		} else if (action.equals("disable")) {
			// If there already is another disable action pending, call the error callback in order
			// to notify that the previous action has been cancelled
			if (this.mContextForDisable != null) {
				this.mContextForDisable.error(1);
				this.mContextForDisable = null;
			}

			if (this.mBluetoothAdapter.isEnabled()) {
				if (!this.mBluetoothAdapter.disable()) {
					callbackContext.error(0);
				} else {
					// Save the context, in order to send the result once the action has been completed
					this.mContextForDisable = callbackContext;
				}
			} else {
				callbackContext.success();
			}
			return true;
		} else if (action.equals("getDevice")) {
			String address = args.getString(0);
			BluetoothDevice device = this.mBluetoothAdapter.getRemoteDevice(address);
			callbackContext.success(this.getDeviceInfo(device));
			return true;
		} else if (action.equals("getDevices")) {
			Set<BluetoothDevice> devices = this.mBluetoothAdapter.getBondedDevices();
			JSONArray deviceInfos = new JSONArray();
			for (BluetoothDevice device : devices) {
				deviceInfos.put(this.getDeviceInfo(device));
			}
			callbackContext.success(deviceInfos);
			return true;
		} else if (action.equals("startDiscovery")) {
			// Automatically cancel any previous discovery
			if (this.mBluetoothAdapter.isDiscovering()) {
				this.mBluetoothAdapter.cancelDiscovery();
			}

// @blab TODO			if (cordova.hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) {
            if (true) {
				this.startDiscovery(callbackContext);
			} else {
				this.getPermission(callbackContext, START_DISCOVERY_REQ_CODE, Manifest.permission.ACCESS_COARSE_LOCATION);
			}
			return true;
		} else if (action.equals("stopDiscovery")) {
			if (this.mBluetoothAdapter.isDiscovering()) {
				if (this.mBluetoothAdapter.cancelDiscovery()) {
					callbackContext.success();
				} else {
					callbackContext.error(0);
				}
			} else {
				callbackContext.success();
			}
			return true;
		} else if (action.equals("requestDiscoverable")) {
			Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);
			discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300);
			this.prepareActivity(action, args, callbackContext, discoverableIntent, REQUEST_DISCOVERABLE_BT);
			return true;
		} else if (action.equals("connect")) {
			final String address = args.getString(0);
			final String uuid = args.getString(1);
			cordova.getThreadPool().execute(new Runnable() {
				public void run() {
					int socketId;
					BluetoothSocket socket;

					try {
						BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(address);
						socket = device.createRfcommSocketToServiceRecord(UUID.fromString(uuid));

						// Note: You should always ensure that the device is not performing
						// device discovery when you call connect().
						// If discovery is in progress, then the connection attempt will be
						// significantly slowed and is more likely to fail.
						mBluetoothAdapter.cancelDiscovery();

						socket.connect();

						socketId = mSocketId.getAndIncrement();
						mClientSockets.put(socketId, socket);
						callbackContext.success(socketId);
					} catch (NullPointerException e) {
						callbackContext.error(e.getMessage());
						return;
					} catch (IllegalArgumentException e) {
						callbackContext.error(e.getMessage());
						return;
					} catch (IOException e) {
						callbackContext.error(e.getMessage());
						return;
					}

					// Now that the connection has been made, begin the read loop
					readLoop(socketId, socket);
				}
			});
			return true;
		} else if (action.equals("close")) {
			int socketId = args.getInt(0);
			BluetoothSocket socket = this.mClientSockets.remove(socketId);
			if (socket != null) {
				// The socketId refers to a client socket
				try {
					socket.close();
					callbackContext.success();
				} catch (IOException e) {
					callbackContext.error(e.getMessage());
				}
			} else {
				BluetoothServerSocket serverSocket = this.mServerSockets.remove(socketId);
				if (serverSocket != null) {
					// The socketId refers to a server socket
					try {
						serverSocket.close();
						callbackContext.success();
					} catch (IOException e) {
						callbackContext.error(e.getMessage());
					}
				} else {
					// Closing an already closed socket is not an error
					callbackContext.success();
				}
			}
			return true;
		} else if (action.equals("send")) {
			int socketId = args.getInt(0);
			byte[] data = args.getArrayBuffer(1);
			BluetoothSocket socket = this.mClientSockets.get(socketId);
			if (socket != null) {
				try {
					// The send operation occurs in a separate thread
					this.mSendQueue.put(new SocketSendData(callbackContext, socket, data));
				} catch (InterruptedException e) {
					callbackContext.error(e.getMessage());
				}
			} else {
				callbackContext.error("Invalid socketId");
			}
			return true;
		} else if (action.equals("listenUsingRfcomm")) {
			final String uuid = args.getString(0);
			cordova.getThreadPool().execute(new Runnable() {
				public void run() {
					int serverSocketId;
					BluetoothServerSocket serverSocket;

					try {
						serverSocket = mBluetoothAdapter.listenUsingRfcommWithServiceRecord(SERVICE_NAME, UUID.fromString(uuid));
						serverSocketId = mSocketId.getAndIncrement();
						mServerSockets.put(serverSocketId, serverSocket);
						callbackContext.success(serverSocketId);
					} catch (NullPointerException e) {
						callbackContext.error(e.getMessage());
						return;
					} catch (IllegalArgumentException e) {
						callbackContext.error(e.getMessage());
						return;
					} catch (IOException e) {
						callbackContext.error(e.getMessage());
						return;
					}

					// Now that the server socket has been made, begin the accept loop
					acceptLoop(serverSocketId, serverSocket);
				}
			});
			return true;
		} else {
			callbackContext.error("Invalid action");
			return false;
		}
	}

	public void getAdapterState(CallbackContext callbackContext, boolean keepCallback) {
		PluginResult pluginResult;

		try {
			JSONObject adapterState = new JSONObject();
			adapterState.put("address", this.mBluetoothAdapter.getAddress());
			adapterState.put("name", this.mBluetoothAdapter.getName());
			adapterState.put("enabled", this.mBluetoothAdapter.isEnabled());
			adapterState.put("discovering", this.mBluetoothAdapter.isDiscovering());
			adapterState.put("discoverable", this.mBluetoothAdapter.getScanMode() == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);

            pluginResult = new PluginResult(PluginResult.Status.OK, adapterState);
            pluginResult.setKeepCallback(keepCallback);
            callbackContext.sendPluginResult(pluginResult);
		} catch (JSONException e) {
            pluginResult = new PluginResult(PluginResult.Status.ERROR, e.getMessage());
            pluginResult.setKeepCallback(keepCallback);
            callbackContext.sendPluginResult(pluginResult);
		}
	}

	public JSONObject getDeviceInfo(BluetoothDevice device) throws JSONException {
		JSONObject deviceInfo = new JSONObject();

		deviceInfo.put("address", device.getAddress());
		deviceInfo.put("name", device.getName());
		deviceInfo.put("paired", device.getBondState() == BluetoothDevice.BOND_BONDED);

		JSONArray deviceUUIDs = new JSONArray();
		ParcelUuid[] uuids = device.getUuids();
		if (uuids != null) {
			for (int i = 0; i < uuids.length; i++) {
				deviceUUIDs.put(uuids[i].toString());
			}
		}
		deviceInfo.put("uuids", deviceUUIDs);

		return deviceInfo;
	}

	public void prepareActivity(String action, CordovaArgs args, CallbackContext callbackContext, Intent intent, int requestCode) {
		// If there already is another activity with this request code, call the error callback in order
		// to notify that the activity has been cancelled
		if (this.mContextForActivity.containsKey(requestCode)) {
			callbackContext.error("Attempted to start the same activity twice");
			return;
		}

		// Store the callbackContext, in order to send the result once the activity has been completed
		this.mContextForActivity.put(requestCode, callbackContext);

		// Store the callbackContext, in order to send the result once the activity has been completed
		cordova.startActivityForResult(this, intent, requestCode);
	}

	@Override
	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
		CallbackContext callbackContext = this.mContextForActivity.remove(requestCode);

		if (callbackContext != null) {
			if (resultCode == Activity.RESULT_CANCELED) {
				callbackContext.error(0);
			} else {
				callbackContext.success();
			}
		} else {
			// TO DO -- This may be a bug on the JavaScript side, as we get here only if the
			// activity has been started twice, before waiting the completion of the first one.
			Log.e(TAG, "BUG: onActivityResult -- (callbackContext == null)");
		}
	}

	public final BroadcastReceiver mReceiver = new BroadcastReceiver() {
		@Override
		public void onReceive(Context context, Intent intent) {
			String action = intent.getAction();
			PluginResult pluginResult;

			if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) {
				int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1);
				int previousState = intent.getIntExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, -1);

				// If there was an enable request pending, send the result
				if ((previousState == BluetoothAdapter.STATE_TURNING_ON) && (mContextForEnable != null)) {
					if (state == BluetoothAdapter.STATE_ON) {
						mContextForEnable.success();
					} else {
						mContextForEnable.error(2);
					}
					mContextForEnable = null;
				}

				// If there was a disable request pending, send the result
				if ((previousState == BluetoothAdapter.STATE_TURNING_OFF) && (mContextForDisable != null)) {
					if (state == BluetoothAdapter.STATE_OFF) {
						mContextForDisable.success();
					} else {
						mContextForDisable.error(2);
					}
					mContextForDisable = null;
				}

				// Send the state changed event only if the state is not a transitioning one
				if ((state == BluetoothAdapter.STATE_OFF) || (state == BluetoothAdapter.STATE_ON)) {
					getAdapterState(mContextForAdapterStateChanged, true);
				}
			} else if (action.equals(BluetoothAdapter.ACTION_DISCOVERY_STARTED) || action.equals(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) {
				getAdapterState(mContextForAdapterStateChanged, true);
			} else if (action.equals(BluetoothDevice.ACTION_FOUND)) {
				try {
					BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
					JSONObject deviceInfo = getDeviceInfo(device);

					pluginResult = new PluginResult(PluginResult.Status.OK, deviceInfo);
					pluginResult.setKeepCallback(true);
					mContextForDeviceAdded.sendPluginResult(pluginResult);
				} catch (JSONException e) {
					pluginResult = new PluginResult(PluginResult.Status.ERROR, e.getMessage());
					pluginResult.setKeepCallback(true);
					mContextForDeviceAdded.sendPluginResult(pluginResult);
				}
			} else if (action.equals(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)) {
				// BUG: The documented EXTRA_PREVIOUS_SCAN_MODE field of the intent is not implemented on Android.
				// For details see:
				// http://stackoverflow.com/questions/30553911/extra-previous-scan-mode-always-returns-an-error-for-android-bluetooth
				// As a workaround, the previous scan mode is handled manually here
				int scanMode = intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1);

				// Report only the transitions from/to SCAN_MODE_CONNECTABLE_DISCOVERABLE
				if ((scanMode == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) || (mPreviousScanMode == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)) {
					getAdapterState(mContextForAdapterStateChanged, true);
				}
				mPreviousScanMode = scanMode;
			}
		}
	};

	public void readLoop(int socketId, BluetoothSocket socket) {
		byte[] readBuffer = new byte[READ_BUFFER_SIZE];
		byte[] data;
		ArrayList<PluginResult> multipartMessages;
		PluginResult pluginResult;

		try {
			InputStream stream = socket.getInputStream();
			int bytesRead;

			while (socket.isConnected()) {
				bytesRead = stream.read(readBuffer);
				if (bytesRead < 0) {
					throw new IOException("Disconnected");
				} else if (bytesRead > 0) {
					data = Arrays.copyOf(readBuffer, bytesRead);
					multipartMessages = new ArrayList<PluginResult>();
					multipartMessages.add(new PluginResult(PluginResult.Status.OK, socketId));
					multipartMessages.add(new PluginResult(PluginResult.Status.OK, data));
					pluginResult = new PluginResult(PluginResult.Status.OK, multipartMessages);
					pluginResult.setKeepCallback(true);
					this.mContextForReceive.sendPluginResult(pluginResult);
				}
			}
		} catch (IOException e) {
			try {
				JSONObject info = new JSONObject();
				info.put("socketId", socketId);
				info.put("errorMessage", e.getMessage());
				pluginResult = new PluginResult(PluginResult.Status.OK, info);
				pluginResult.setKeepCallback(true);
				this.mContextForReceiveError.sendPluginResult(pluginResult);
			} catch (JSONException ex) {}
		}

		try {
			socket.close();
		} catch (IOException e) {}

		// The socket has been closed, remove its socketId
		this.mClientSockets.remove(socketId);
	}

	public void acceptLoop(int serverSocketId, BluetoothServerSocket serverSocket) {
		int clientSocketId;
		BluetoothSocket clientSocket;
		ArrayList<PluginResult> multipartMessages;
		PluginResult pluginResult;

		try {
			while (true) {
				clientSocket = serverSocket.accept();
				if (clientSocket == null) {
					throw new IOException("Disconnected");
				}

				clientSocketId = this.mSocketId.getAndIncrement();
				this.mClientSockets.put(clientSocketId, clientSocket);

				multipartMessages = new ArrayList<PluginResult>();
				multipartMessages.add(new PluginResult(PluginResult.Status.OK, serverSocketId));
				multipartMessages.add(new PluginResult(PluginResult.Status.OK, clientSocketId));
				pluginResult = new PluginResult(PluginResult.Status.OK, multipartMessages);
				pluginResult.setKeepCallback(true);
				this.mContextForAccept.sendPluginResult(pluginResult);

				this.newReadLoopThread(clientSocketId, clientSocket);
			}
		} catch (IOException e) {
			try {
				JSONObject info = new JSONObject();
				info.put("socketId", serverSocketId);
				info.put("errorMessage", e.getMessage());
				pluginResult = new PluginResult(PluginResult.Status.OK, info);
				pluginResult.setKeepCallback(true);
				this.mContextForAcceptError.sendPluginResult(pluginResult);
			} catch (JSONException ex) {}
		}

		try {
			serverSocket.close();
		} catch (IOException e) {}

		// The socket has been closed, remove its socketId
		this.mServerSockets.remove(serverSocketId);
	}

	public void newReadLoopThread(final int socketId, final BluetoothSocket socket) {
		cordova.getThreadPool().execute(new Runnable() {
			public void run() {
				readLoop(socketId, socket);
			}
		});
	}

	public void writeLoop() {
		SocketSendData sendData;

		try {
			while (true) {
				sendData = this.mSendQueue.take();

				try {
					sendData.mSocket.getOutputStream().write(sendData.mData);
					sendData.mCallbackContext.success(sendData.mData.length);
				} catch (IOException e) {
					sendData.mCallbackContext.error(e.getMessage());
				}
			}
		} catch (InterruptedException e) {}
	}

	public void startDiscovery(CallbackContext callbackContext) {
		if (!this.mDeviceAddedRegistered) {
			IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND);
			cordova.getActivity().registerReceiver(this.mReceiver, filter);
			this.mDeviceAddedRegistered = true;
		}

		if (this.mBluetoothAdapter.startDiscovery()) {
			callbackContext.success();
		} else {
			callbackContext.error(0);
		}
	}

	public void getPermission(CallbackContext callbackContext, int requestCode, String permission) {
		// If there already is another permission request with this request code, call the error callback in order
		// to notify that the request has been cancelled
		if (this.mContextForPermission.containsKey(requestCode)) {
			callbackContext.error("Attempted to request the same permission twice");
			return;
		}

		// Store the callbackContext, in order to send the result once the activity has been completed
		this.mContextForPermission.put(requestCode, callbackContext);

        // @blab TODO
		// cordova.requestPermission(this, requestCode, permission);
	}

	// @blab TODO @Override
	public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException {
		CallbackContext callbackContext = this.mContextForPermission.remove(requestCode);

		if (requestCode == START_DISCOVERY_REQ_CODE) {
			if ((grantResults.length > 0) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
				this.startDiscovery(callbackContext);
			} else {
				callbackContext.error(0);
			}
		}
	}
}

