/*
 * Copyright (c) 2015, Nordic Semiconductor
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
 * documentation and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
 * software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
 * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package no.nordicsemi.android.nrftoolbox.ble;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Handler;
import androidx.annotation.RequiresApi;

import java.util.Deque;
import java.util.LinkedList;
import java.util.Queue;
import java.util.UUID;

import no.nordicsemi.android.nrftoolbox.utility.DebugLogger;

/**
 * DO NOT EDIT THIS FILE UNLESS NECESSARY!
 * The BleManager should be overridden in your app and all the 'high level' callbacks should be called from there.
 * Keeping this file as is (and {@link BleManagerCallbacks} as well) will allow to quickly update it when an update is posted here.
 *
 * <p>The BleManager is responsible for managing the low level communication with a Bluetooth Low Energy device. Please see profiles implementation for an example of use.
 * This base manager has been tested against number of devices and samples from Nordic SDK.</p>
 * <p>The manager handles connection events and initializes the device after establishing the connection.
 * <ol>
 * <li>For bonded devices it ensures that the Service Changed indications, if this characteristic is present, are enabled. Android does not enable them by default,
 * leaving this to the developers.</li>
 * <li>The manager tries to read the Battery Level characteristic. No matter the result of this operation (for example the Battery Level characteristic may not have the READ property)
 * it tries to enable Battery Level notifications, to get battery updates from the device.</li>
 * <li>Afterwards, the manager initializes the device using given queue of commands. See {@link BleProfile#initGatt(BluetoothGatt)} method for more details.</li>
 * <li>When initialization complete, the {@link BleManagerCallbacks#onDeviceReady(BluetoothDevice)} callback is called.</li>
 * </ol>
 * </p>
 * <p>Events from all profiles are being logged into the nRF Logger application,
 * which may be downloaded from Google Play: <a href="https://play.google.com/store/apps/details?id=no.nordicsemi.android.log">https://play.google.com/store/apps/details?id=no.nordicsemi.android.log</a></p>
 * <p>The nRF Logger application allows you to see application logs without need to connect it to the computer.</p>
 */
public class BleManager implements BleProfileApi {
	private final static String TAG = "BleManager";

	private final static UUID CLIENT_CHARACTERISTIC_CONFIG_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");

	private final static UUID BATTERY_SERVICE = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb");
	private final static UUID BATTERY_LEVEL_CHARACTERISTIC = UUID.fromString("00002A19-0000-1000-8000-00805f9b34fb");

	private final static UUID GENERIC_ATTRIBUTE_SERVICE = UUID.fromString("00001801-0000-1000-8000-00805f9b34fb");
	private final static UUID SERVICE_CHANGED_CHARACTERISTIC = UUID.fromString("00002A05-0000-1000-8000-00805f9b34fb");

	private final Object lock = new Object();

	protected final BleManagerCallbacks callbacks;
	private final Context context;
	private final Handler handler;
	protected BluetoothDevice bluetoothDevice;
	protected BleProfile profile;
	private BluetoothGatt bluetoothGatt;
	private BleManagerGattCallback gattCallback;
	/**
	 * This flag is set to false only when the {@link #shouldAutoConnect()} method returns true and the device got disconnected without calling {@link #disconnect()} method.
	 * If {@link #shouldAutoConnect()} returns false (default) this is always set to true.
	 */
	private boolean userDisconnected;
	/**
	 * Flag set to true when {@link #shouldAutoConnect()} method returned <code>true</code>. The first connection attempt is done with <code>autoConnect</code>
	 * flag set to false (to make the first connection quick) but on connection lost the manager will call {@link #connect(BluetoothDevice)} again.
	 * This time this method will call {@link BluetoothGatt#connect()} which always uses <code>autoConnect</code> equal true.
	 */
	private boolean initialConnection;
	/** Flag set to true when the device is connected. */
	private boolean connected;
	private int connectionState = BluetoothGatt.STATE_DISCONNECTED;
	/** Last received battery value or -1 if value wasn't received. */
	private int batteryValue = -1;
	/**
	 * The current MTU (Maximum Transfer Unit). The maximum number of bytes that can be sent in a single packet is MTU-3.
	 */
	private int mtu = 23;

	private final BroadcastReceiver bluetoothStateBroadcastReceiver = new BroadcastReceiver() {
		@Override
		public void onReceive(final Context context, final Intent intent) {
			final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF);
			final int previousState = intent.getIntExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, BluetoothAdapter.STATE_OFF);

			switch (state) {
				case BluetoothAdapter.STATE_TURNING_OFF:
				case BluetoothAdapter.STATE_OFF:
					if (connected && previousState != BluetoothAdapter.STATE_TURNING_OFF && previousState != BluetoothAdapter.STATE_OFF) {
						// The connection is killed by the system, no need to gently disconnect
						gattCallback.notifyDeviceDisconnected(bluetoothDevice);
					}
					close();
					break;
			}
		}
	};

	private BroadcastReceiver bondingBroadcastReceiver = new BroadcastReceiver() {
		@Override
		public void onReceive(final Context context, final Intent intent) {
			final BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
			final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1);
			final int previousBondState = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1);

			// Skip other devices
			if (bluetoothGatt == null || !device.getAddress().equals(bluetoothGatt.getDevice().getAddress()))
				return;

			DebugLogger.i(TAG, "Bond state changed for: " + device.getName() + " new state: " + bondState + " previous: " + previousBondState);

			switch (bondState) {
				case BluetoothDevice.BOND_BONDING:
					callbacks.onBondingRequired(device);
					break;
				case BluetoothDevice.BOND_BONDED:
					callbacks.onBonded(device);

					// Start initializing again.
					// In fact, bonding forces additional, internal service discovery (at least on Nexus devices), so this method may safely be used to start this process again.
					bluetoothGatt.discoverServices();
					break;
			}
		}
	};

	public BleManager(final Context context, final BleManagerCallbacks callbacks) {
		this.callbacks = callbacks;
		this.context = context;
		this.handler = new Handler();

		// Register bonding broadcast receiver
		context.registerReceiver(bondingBroadcastReceiver, new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED));
	}

	/**
	 * Returns the Profile API. Profile may be null if service discovery has not been performed or the device does not match any profile.
	 */
	public BleProfile getProfile() {
		return profile;
	}

	/**
	 * Returns the context that the manager was created with.
	 *
	 * @return the context
	 */
	@Override
	public Context getContext() {
		return context;
	}

	/**
	 * Returns whether to connect to the remote device just once (false) or to add the address to white list of devices
	 * that will be automatically connect as soon as they become available (true). In the latter case, if
	 * Bluetooth adapter is enabled, Android scans periodically for devices from the white list and if a advertising packet
	 * is received from such, it tries to connect to it. When the connection is lost, the system will keep trying to reconnect
	 * to it in. If true is returned, and the connection to the device is lost the {@link BleManagerCallbacks#onLinklossOccurred(BluetoothDevice)}
	 * callback is called instead of {@link BleManagerCallbacks#onDeviceDisconnected(BluetoothDevice)}.
	 * <p>This feature works much better on newer Android phone models and many not work on older phones.</p>
	 * <p>This method should only be used with bonded devices, as otherwise the device may change it's address.
	 * It will however work also with non-bonded devices with private static address. A connection attempt to
	 * a device with private resolvable address will fail.</p>
	 * <p>The first connection to a device will always be created with autoConnect flag to false
	 * (see {@link BluetoothDevice#connectGatt(Context, boolean, BluetoothGattCallback)}). This is to make it quick as the
	 * user most probably waits for a quick response. However, if this method returned true during first connection and the link was lost,
	 * the manager will try to reconnect to it using {@link BluetoothGatt#connect()} which forces autoConnect to true .</p>
	 *
	 * @return autoConnect flag value
	 */
	protected boolean shouldAutoConnect() {
		return false;
	}

	/**
	 * Connects to the Bluetooth Smart device.
	 *
	 * @param device a device to connect to
	 */
	public void connect(final BluetoothDevice device) {
		if (connected)
			return;

		synchronized (lock) {
			if (bluetoothGatt != null) {
				// There are 2 ways of reconnecting to the same device:
				// 1. Reusing the same BluetoothGatt object and calling connect() - this will force the autoConnect flag to true
				// 2. Closing it and reopening a new instance of BluetoothGatt object.
				// The gatt.close() is an asynchronous method. It requires some time before it's finished and
				// device.connectGatt(...) can't be called immediately or service discovery
				// may never finish on some older devices (Nexus 4, Android 5.0.1).
				// If shouldAutoConnect() method returned false we can't call gatt.connect() and have to close gatt and open it again.
				if (!initialConnection) {
					bluetoothGatt.close();
					bluetoothGatt = null;
					try {
						Thread.sleep(200); // Is 200 ms enough?
					} catch (final InterruptedException e) {
						// Ignore
					}
				} else {
					// Instead, the gatt.connect() method will be used to reconnect to the same device.
					// This method forces autoConnect = true even if the gatt was created with this flag set to false.
					initialConnection = false;
					connectionState = BluetoothGatt.STATE_CONNECTING;
					callbacks.onDeviceConnecting(device);
					bluetoothGatt.connect();
					return;
				}
			} else {
				// Register bonding broadcast receiver
				context.registerReceiver(bluetoothStateBroadcastReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
				context.registerReceiver(bondingBroadcastReceiver, new IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED));
			}
		}

		final boolean shouldAutoConnect = shouldAutoConnect();
		userDisconnected = !shouldAutoConnect; // We will receive Linkloss events only when the device is connected with autoConnect=true
		// The first connection will always be done with autoConnect = false to make the connection quick.
		// If the shouldAutoConnect() method returned true, the manager will automatically try to reconnect to this device on link loss.
		if (shouldAutoConnect)
			initialConnection = true;
		bluetoothDevice = device;
		connectionState = BluetoothGatt.STATE_CONNECTING;
		callbacks.onDeviceConnecting(device);
		bluetoothGatt = device.connectGatt(context, false, gattCallback = new BleManagerGattCallback());
	}

	/**
	 * Disconnects from the device or cancels the pending connection attempt. Does nothing if device was not connected.
	 * @return true if device is to be disconnected. False if it was already disconnected.
	 */
	public boolean disconnect() {
		userDisconnected = true;
		initialConnection = false;

		if (bluetoothGatt != null) {
			connectionState = BluetoothGatt.STATE_DISCONNECTING;
			callbacks.onDeviceDisconnecting(bluetoothGatt.getDevice());
			final boolean wasConnected = connected;
			bluetoothGatt.disconnect();

			if (!wasConnected) {
				// There will be no callback, the connection attempt will be stopped
				connectionState = BluetoothGatt.STATE_DISCONNECTED;
				callbacks.onDeviceDisconnected(bluetoothGatt.getDevice());
			}
			return true;
		}
		return false;
	}

	/**
	 * This method returns true if the device is connected. Services could have not been discovered yet.
	 */
	public boolean isConnected() {
		return connected;
	}

	/**
	 * Method returns the connection state:
	 * {@link BluetoothGatt#STATE_CONNECTING STATE_CONNECTING},
	 * {@link BluetoothGatt#STATE_CONNECTED STATE_CONNECTED},
	 * {@link BluetoothGatt#STATE_DISCONNECTING STATE_DISCONNECTING},
	 * {@link BluetoothGatt#STATE_DISCONNECTED STATE_DISCONNECTED}
	 * @return the connection state
	 */
	public int getConnectionState() {
		return connectionState;
	}

	/**
	 * Returns the last received value of Battery Level characteristic, or -1 if such does not exist, hasn't been read or notification wasn't received yet.
	 * @return the last battery level value in percent
	 */
	public int getBatteryValue() {
		return batteryValue;
	}

	/**
	 * Closes and releases resources. May be also used to unregister broadcast listeners.
	 */
	public void close() {
		try {
			context.unregisterReceiver(bluetoothStateBroadcastReceiver);
			context.unregisterReceiver(bondingBroadcastReceiver);
		} catch (Exception e) {
			// the receiver must have been not registered or unregistered before
		}
		synchronized (lock) {
			if (bluetoothGatt != null) {
				bluetoothGatt.close();
				bluetoothGatt = null;
			}
			connected = false;
			initialConnection = false;
			connectionState = BluetoothGatt.STATE_DISCONNECTED;
			gattCallback = null;
			bluetoothDevice = null;
		}
	}

	@Override
	public final boolean createBond() {
		return enqueue(Request.createBond());
	}

	/**
	 * Creates a bond with the device. The device must be first set using {@link #connect(BluetoothDevice)} which will
	 * try to connect to the device. If you need to pair with a device before connecting to it you may do it without
	 * the use of BleManager object and connect after bond is established.
	 * @return true if pairing has started, false if it was already paired or an immediate error occur.
	 */
	private boolean internalCreateBond() {
		final BluetoothDevice device = bluetoothDevice;
		if (device == null)
			return false;

		if (device.getBondState() == BluetoothDevice.BOND_BONDED)
			return false;

		return device.createBond();
	}

	/**
	 * When the device is bonded and has the Generic Attribute service and the Service Changed characteristic this method enables indications on this characteristic.
	 * In case one of the requirements is not fulfilled this method returns <code>false</code>.
	 *
	 * @return <code>true</code> when the request has been sent, <code>false</code> when the device is not bonded, does not have the Generic Attribute service, the GA service does not have
	 * the Service Changed characteristic or this characteristic does not have the CCCD.
	 */
	private boolean ensureServiceChangedEnabled() {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null)
			return false;

		// The Service Changed indications have sense only on bonded devices
		final BluetoothDevice device = gatt.getDevice();
		if (device.getBondState() != BluetoothDevice.BOND_BONDED)
			return false;

		final BluetoothGattService gaService = gatt.getService(GENERIC_ATTRIBUTE_SERVICE);
		if (gaService == null)
			return false;

		final BluetoothGattCharacteristic scCharacteristic = gaService.getCharacteristic(SERVICE_CHANGED_CHARACTERISTIC);
		if (scCharacteristic == null)
			return false;

		return internalEnableIndications(scCharacteristic);
	}

	@Override
	public final boolean enableNotifications(final BluetoothGattCharacteristic characteristic) {
		return enqueue(Request.newEnableNotificationsRequest(characteristic));
	}

	private boolean internalEnableNotifications(final BluetoothGattCharacteristic characteristic) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || characteristic == null)
			return false;

		// Check characteristic property
		final int properties = characteristic.getProperties();
		if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0)
			return false;

		gatt.setCharacteristicNotification(characteristic, true);
		final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_DESCRIPTOR_UUID);
		if (descriptor != null) {
			descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
			return internalWriteDescriptorWorkaround(descriptor);
		}
		return false;
	}

	@Override
	public final boolean enableIndications(final BluetoothGattCharacteristic characteristic) {
		return enqueue(Request.newEnableIndicationsRequest(characteristic));
	}

	private boolean internalEnableIndications(final BluetoothGattCharacteristic characteristic) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || characteristic == null)
			return false;

		// Check characteristic property
		final int properties = characteristic.getProperties();
		if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == 0)
			return false;

		gatt.setCharacteristicNotification(characteristic, true);
		final BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_DESCRIPTOR_UUID);
		if (descriptor != null) {
			descriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE);
			return internalWriteDescriptorWorkaround(descriptor);
		}
		return false;
	}

	@Override
	public final boolean readCharacteristic(final BluetoothGattCharacteristic characteristic) {
		return enqueue(Request.newReadRequest(characteristic));
	}

	private boolean internalReadCharacteristic(final BluetoothGattCharacteristic characteristic) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || characteristic == null)
			return false;

		// Check characteristic property
		final int properties = characteristic.getProperties();
		if ((properties & BluetoothGattCharacteristic.PROPERTY_READ) == 0)
			return false;

		return gatt.readCharacteristic(characteristic);
	}

	@Override
	public final boolean writeCharacteristic(final BluetoothGattCharacteristic characteristic) {
		return enqueue(Request.newWriteRequest(characteristic, characteristic.getValue()));
	}

	private boolean internalWriteCharacteristic(final BluetoothGattCharacteristic characteristic) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || characteristic == null)
			return false;

		// Check characteristic property
		final int properties = characteristic.getProperties();
		if ((properties & (BluetoothGattCharacteristic.PROPERTY_WRITE | BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)) == 0)
			return false;

		return gatt.writeCharacteristic(characteristic);
	}

	@Override
	public final boolean readDescriptor(final BluetoothGattDescriptor descriptor) {
		return enqueue(Request.newReadRequest(descriptor));
	}

	private boolean internalReadDescriptor(final BluetoothGattDescriptor descriptor) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || descriptor == null)
			return false;

		return gatt.readDescriptor(descriptor);
	}

	@Override
	public final boolean writeDescriptor(final BluetoothGattDescriptor descriptor) {
		return enqueue(Request.newWriteRequest(descriptor, descriptor.getValue()));
	}

	private boolean internalWriteDescriptor(final BluetoothGattDescriptor descriptor) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || descriptor == null)
			return false;

		return internalWriteDescriptorWorkaround(descriptor);
	}

	@Override
	public final boolean readBatteryLevel() {
		return enqueue(Request.newReadBatteryLevelRequest());
	}

	private boolean internalReadBatteryLevel() {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null)
			return false;

		final BluetoothGattService batteryService = gatt.getService(BATTERY_SERVICE);
		if (batteryService == null)
			return false;

		final BluetoothGattCharacteristic batteryLevelCharacteristic = batteryService.getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC);
		if (batteryLevelCharacteristic == null)
			return false;

		// Check characteristic property
		final int properties = batteryLevelCharacteristic.getProperties();
		if ((properties & BluetoothGattCharacteristic.PROPERTY_READ) == 0)
			return false;

		return internalReadCharacteristic(batteryLevelCharacteristic);
	}

	@Override
	public final boolean setBatteryNotifications(final boolean enable) {
		if (enable)
			return enqueue(Request.newEnableBatteryLevelNotificationsRequest());
		else
			return enqueue(Request.newDisableBatteryLevelNotificationsRequest());
	}

	private boolean internalSetBatteryNotifications(final boolean enable) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null) {
			return false;
		}

		final BluetoothGattService batteryService = gatt.getService(BATTERY_SERVICE);
		if (batteryService == null)
			return false;

		final BluetoothGattCharacteristic batteryLevelCharacteristic = batteryService.getCharacteristic(BATTERY_LEVEL_CHARACTERISTIC);
		if (batteryLevelCharacteristic == null)
			return false;

		// Check characteristic property
		final int properties = batteryLevelCharacteristic.getProperties();
		if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0)
			return false;

		gatt.setCharacteristicNotification(batteryLevelCharacteristic, enable);
		final BluetoothGattDescriptor descriptor = batteryLevelCharacteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_DESCRIPTOR_UUID);
		if (descriptor != null) {
			if (enable) {
				descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
			} else {
				descriptor.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE);
			}
			return internalWriteDescriptorWorkaround(descriptor);
		}
		return false;
	}

	/**
	 * There was a bug in Android up to 6.0 where the descriptor was written using parent
	 * characteristic's write type, instead of always Write With Response, as the spec says.
	 * <p>
	 *     See: <a href="https://android.googlesource.com/platform/frameworks/base/+/942aebc95924ab1e7ea1e92aaf4e7fc45f695a6c%5E%21/#F0">
	 *         https://android.googlesource.com/platform/frameworks/base/+/942aebc95924ab1e7ea1e92aaf4e7fc45f695a6c%5E%21/#F0</a>
	 * </p>
	 * @param descriptor the descriptor to be written
	 * @return the result of {@link BluetoothGatt#writeDescriptor(BluetoothGattDescriptor)}
	 */
	private boolean internalWriteDescriptorWorkaround(final BluetoothGattDescriptor descriptor) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null || descriptor == null)
			return false;

		final BluetoothGattCharacteristic parentCharacteristic = descriptor.getCharacteristic();
		final int originalWriteType = parentCharacteristic.getWriteType();
		parentCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
		final boolean result = gatt.writeDescriptor(descriptor);
		parentCharacteristic.setWriteType(originalWriteType);
		return result;
	}

	@Override
	public final boolean requestMtu(final int mtu) {
		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && enqueue(Request.newMtuRequest(mtu));
	}

	@Override
	public final int getMtu() {
		return mtu;
	}

	@Override
	public final void overrideMtu(final int mtu) {
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
			BleManager.this.mtu = mtu;
		}
	}

	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
	private boolean internalRequestMtu(final int mtu) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null)
			return false;

		return gatt.requestMtu(mtu);
	}

	@Override
	public final boolean requestConnectionPriority(final int priority) {
		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
				&& enqueue(Request.newConnectionPriorityRequest(priority));
	}

	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
	private boolean internalRequestConnectionPriority(final int priority) {
		final BluetoothGatt gatt = bluetoothGatt;
		if (gatt == null)
			return false;

		return gatt.requestConnectionPriority(priority);
	}

	@Override
	public boolean enqueue(final Request request) {
		if (gattCallback != null) {
			// Add the new task to the end of the queue
			gattCallback.taskQueue.add(request);
			gattCallback.nextRequest();
			return true;
		}
		return false;
	}

	private final class BleManagerGattCallback extends BluetoothGattCallback {
		private final static String ERROR_CONNECTION_STATE_CHANGE = "Error on connection state change";
		private final static String ERROR_DISCOVERY_SERVICE = "Error on discovering services";
		private final static String ERROR_AUTH_ERROR_WHILE_BONDED = "Phone has lost bonding information";
		private final static String ERROR_READ_CHARACTERISTIC = "Error on reading characteristic";
		private final static String ERROR_WRITE_CHARACTERISTIC = "Error on writing characteristic";
		private final static String ERROR_READ_DESCRIPTOR = "Error on reading descriptor";
		private final static String ERROR_WRITE_DESCRIPTOR = "Error on writing descriptor";
		private final static String ERROR_MTU_REQUEST = "Error on mtu request";
		private final static String ERROR_CONNECTION_PRIORITY_REQUEST = "Error on connection priority request";

		private final Queue<Request> taskQueue = new LinkedList<>();
		private Deque<Request> initQueue;
		private boolean initInProgress;
		private boolean operationInProgress = true;
		/**
		 * This flag is required to resume operations after the connection priority request was made.
		 * It is used only on Android Oreo and newer, as only there there is onConnectionUpdated callback.
		 * However, as this callback is triggered every time the connection parameters change, even
		 * when such request wasn't made, this flag ensures the nextRequest() method won't be called
		 * during another operation.
		 */
		private boolean connectionPriorityOperationInProgress = false;

		private void notifyDeviceDisconnected(final BluetoothDevice device) {
			connected = false;
			connectionState = BluetoothGatt.STATE_DISCONNECTED;
			if (userDisconnected) {
				callbacks.onDeviceDisconnected(device);
				close();
			} else {
				callbacks.onLinklossOccurred(device);
				// We are not closing the connection here as the device should try to reconnect automatically.
				// This may be only called when the shouldAutoConnect() method returned true.
			}
			if (profile != null)
				profile.release();
		}

		private void onError(final BluetoothDevice device, final String message, final int errorCode) {
			callbacks.onError(device, message, errorCode);
			if (profile != null)
				profile.onError(message, errorCode);
		}

		@Override
		public final void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) {
			if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
				// Notify the parent activity/service
				connected = true;
				connectionState = BluetoothGatt.STATE_CONNECTED;
				callbacks.onDeviceConnected(gatt.getDevice());

				/*
				 * The onConnectionStateChange event is triggered just after the Android connects to a device.
				 * In case of bonded devices, the encryption is reestablished AFTER this callback is called.
				 * Moreover, when the device has Service Changed indication enabled, and the list of services has changed (e.g. using the DFU),
				 * the indication is received few hundred milliseconds later, depending on the connection interval.
				 * When received, Android will start performing a service discovery operation on its own, internally,
				 * and will NOT notify the app that services has changed.
				 *
				 * If the gatt.discoverServices() method would be invoked here with no delay, if would return cached services,
				 * as the SC indication wouldn't be received yet.
				 * Therefore we have to postpone the service discovery operation until we are (almost, as there is no such callback) sure,
				 * that it has been handled.
				 * TODO: Please calculate the proper delay that will work in your solution.
				 * It should be greater than the time from LLCP Feature Exchange to ATT Write for Service Change indication.
				 * If your device does not use Service Change indication (for example does not have DFU) the delay may be 0.
				 */
				final boolean bonded = gatt.getDevice().getBondState() == BluetoothDevice.BOND_BONDED;
				final int delay = bonded ? 1600 : 0; // around 1600 ms is required when connection interval is ~45ms.
				handler.postDelayed(() -> {
					// Some proximity tags (e.g. nRF PROXIMITY) initialize bonding automatically when connected.
					if (gatt.getDevice().getBondState() != BluetoothDevice.BOND_BONDING) {
						gatt.discoverServices();
					}
				}, delay);
			} else {
				if (newState == BluetoothProfile.STATE_DISCONNECTED) {
					operationInProgress = true; // no more calls are possible
					initQueue = null;
					taskQueue.clear();
					final boolean wasConnected = connected;
					// if (connected) { // Checking connected prevents from calling onDeviceDisconnected if connection attempt failed. This check is not necessary
						notifyDeviceDisconnected(gatt.getDevice()); // This sets the connected flag to false
					// }
					// Try to reconnect if the initial connection was lost because of a link loss or timeout, and shouldAutoConnect() returned true during connection attempt.
					// This time it will set the autoConnect flag to true (gatt.connect() forces autoConnect true)
					if (initialConnection) {
						connect(gatt.getDevice());
					}

					if (wasConnected || status == BluetoothGatt.GATT_SUCCESS)
						return;
				}

				// TODO Should the disconnect method be called or the connection is still valid? Does this ever happen?
				profile.onError(ERROR_CONNECTION_STATE_CHANGE, status);
			}
		}

		@Override
		public final void onServicesDiscovered(final BluetoothGatt gatt, final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				final BleProfile profile = BleProfileProvider.findProfile(gatt);
				if (profile != null) {
					profile.setApi(BleManager.this);
					BleManager.this.profile = profile;

					// Obtain the queue of initialization requests
					initInProgress = true;
					initQueue = profile.initGatt(gatt);

					// Before we start executing the initialization queue some other tasks need to be done.
					if (initQueue == null)
						initQueue = new LinkedList<>();

					// Note, that operations are added in reverse order to the front of the queue.

					// 3. Enable Battery Level notifications if required (if this char. does not exist, this operation will be skipped)
					if (callbacks.shouldEnableBatteryLevelNotifications(gatt.getDevice()))
						initQueue.addFirst(Request.newEnableBatteryLevelNotificationsRequest());
					// 2. Read Battery Level characteristic (if such does not exist, this will be skipped)
					initQueue.addFirst(Request.newReadBatteryLevelRequest());
					// 1. On devices running Android 4.3-5.x, 8.x and 9.0 the Service Changed
					//    characteristic needs to be enabled by the app (for bonded devices).
					//    The request will be ignored if there is no Service Changed characteristic.
					// This "fix" broke this in Android 8:
					// https://android-review.googlesource.com/c/platform/system/bt/+/239970
					if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
							|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O
							|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
							|| Build.VERSION.SDK_INT == Build.VERSION_CODES.P)
						initQueue.addFirst(Request.newEnableServiceChangedIndicationsRequest());

					operationInProgress = false;
					nextRequest();
				} else {
					callbacks.onDeviceNotSupported(gatt.getDevice());
					disconnect();
				}
			} else {
				DebugLogger.e(TAG, "onServicesDiscovered error " + status);
				onError(gatt.getDevice(), ERROR_DISCOVERY_SERVICE, status);
			}
		}

		@Override
		public final void onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				if (isBatteryLevelCharacteristic(characteristic)) {
					final int batteryValue = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0);
					BleManager.this.batteryValue = batteryValue;
					profile.onBatteryValueReceived(gatt, batteryValue);
				} else {
					// The value has been read. Notify the profile and proceed with the initialization queue.
					profile.onCharacteristicRead(gatt, characteristic);
				}
				operationInProgress = false;
				nextRequest();
			} else if (status == BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION) {
				if (gatt.getDevice().getBondState() != BluetoothDevice.BOND_NONE) {
					// This should never happen but it used to: http://stackoverflow.com/a/20093695/2115352
					DebugLogger.w(TAG, ERROR_AUTH_ERROR_WHILE_BONDED);
					onError(gatt.getDevice(), ERROR_AUTH_ERROR_WHILE_BONDED, status);
				}
			} else {
				DebugLogger.e(TAG, "onCharacteristicRead error " + status);
				onError(gatt.getDevice(), ERROR_READ_CHARACTERISTIC, status);
			}
		}

		@Override
		public final void onCharacteristicWrite(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				// The value has been written. Notify the profile and proceed with the initialization queue.
				profile.onCharacteristicWrite(gatt, characteristic);
				operationInProgress = false;
				nextRequest();
			} else if (status == BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION) {
				if (gatt.getDevice().getBondState() != BluetoothDevice.BOND_NONE) {
					// This should never happen but it used to: http://stackoverflow.com/a/20093695/2115352
					DebugLogger.w(TAG, ERROR_AUTH_ERROR_WHILE_BONDED);
					onError(gatt.getDevice(), ERROR_AUTH_ERROR_WHILE_BONDED, status);
				}
			} else {
				DebugLogger.e(TAG, "onCharacteristicWrite error " + status);
				onError(gatt.getDevice(), ERROR_WRITE_CHARACTERISTIC, status);
			}
		}

		@Override
		public void onDescriptorRead(final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				// The value has been read. Notify the profile and proceed with the initialization queue.
				profile.onDescriptorRead(gatt, descriptor);
				operationInProgress = false;
				nextRequest();
			} else if (status == BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION) {
				if (gatt.getDevice().getBondState() != BluetoothDevice.BOND_NONE) {
					// This should never happen but it used to: http://stackoverflow.com/a/20093695/2115352
					DebugLogger.w(TAG, ERROR_AUTH_ERROR_WHILE_BONDED);
					onError(gatt.getDevice(), ERROR_AUTH_ERROR_WHILE_BONDED, status);
				}
			} else {
				DebugLogger.e(TAG, "onDescriptorRead error " + status);
				onError(gatt.getDevice(), ERROR_READ_DESCRIPTOR, status);
			}
		}

		@Override
		public final void onDescriptorWrite(final BluetoothGatt gatt, final BluetoothGattDescriptor descriptor, final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				// The value has been written. Notify the profile and proceed with the initialization queue.
				profile.onDescriptorWrite(gatt, descriptor);
				operationInProgress = false;
				nextRequest();
			} else if (status == BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION) {
				if (gatt.getDevice().getBondState() != BluetoothDevice.BOND_NONE) {
					// This should never happen but it used to: http://stackoverflow.com/a/20093695/2115352
					DebugLogger.w(TAG, ERROR_AUTH_ERROR_WHILE_BONDED);
					onError(gatt.getDevice(), ERROR_AUTH_ERROR_WHILE_BONDED, status);
				}
			} else {
				DebugLogger.e(TAG, "onDescriptorWrite error " + status);
				onError(gatt.getDevice(), ERROR_WRITE_DESCRIPTOR, status);
			}
		}

		@Override
		public final void onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
			if (isBatteryLevelCharacteristic(characteristic)) {
				final int batteryValue = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 0);
				BleManager.this.batteryValue = batteryValue;
				profile.onBatteryValueReceived(gatt, batteryValue);
			} else {
				final BluetoothGattDescriptor cccd = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_DESCRIPTOR_UUID);
				final boolean notifications = cccd == null || cccd.getValue() == null || cccd.getValue().length != 2 || cccd.getValue()[0] == 0x01;

				if (notifications) {
					profile.onCharacteristicNotified(gatt, characteristic);
				} else { // indications
					profile.onCharacteristicIndicated(gatt, characteristic);
				}
			}
		}

		@Override
		public void onMtuChanged(final BluetoothGatt gatt, final int mtu, final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				profile.onMtuChanged(mtu);
			} else {
				DebugLogger.e(TAG, "onMtuChanged error: " + status + ", mtu: " + mtu);
				onError(gatt.getDevice(), ERROR_MTU_REQUEST, status);
			}
			operationInProgress = false;
			nextRequest();
		}

		// @Override
		/**
		 * Callback indicating the connection parameters were updated. Works on Android 8+.
		 *
		 * @param gatt GATT client involved
		 * @param interval Connection interval used on this connection, 1.25ms unit. Valid range is from
		 * 6 (7.5ms) to 3200 (4000ms).
		 * @param latency Slave latency for the connection in number of connection events. Valid range
		 * is from 0 to 499
		 * @param timeout Supervision timeout for this connection, in 10ms unit. Valid range is from 10
		 * (0.1s) to 3200 (32s)
		 * @param status {@link BluetoothGatt#GATT_SUCCESS} if the connection has been updated
		 * successfully
		 */
		public void onConnectionUpdated(final BluetoothGatt gatt, final int interval, final int latency, final int timeout,	final int status) {
			if (status == BluetoothGatt.GATT_SUCCESS) {
				profile.onConnectionUpdated(interval, latency, timeout);
			} else if (status == 0x3b) { // HCI_ERR_UNACCEPT_CONN_INTERVAL
				DebugLogger.e(TAG, "onConnectionUpdated received status: Unacceptable connection interval, interval: " + interval + ", latency: " + latency + ", timeout: " + timeout);
			} else {
				DebugLogger.e(TAG, "onConnectionUpdated received status: " + status + ", interval: " + interval + ", latency: " + latency + ", timeout: " + timeout);
				callbacks.onError(gatt.getDevice(), ERROR_CONNECTION_PRIORITY_REQUEST, status);
			}
			if (connectionPriorityOperationInProgress) {
				connectionPriorityOperationInProgress = false;
				operationInProgress = false;
				nextRequest();
			}
		}

		/**
		 * Executes the next request. If the last element from the initialization queue has been executed
		 * the {@link BleManagerCallbacks#onDeviceReady(BluetoothDevice)} callback is called.
		 */
		private void nextRequest() {
			if (operationInProgress)
				return;

			// Get the first request from the init queue
			Request request = initQueue != null ? initQueue.poll() : null;

			// Are we done with initializing?
			if (request == null) {
				if (initInProgress) {
					initQueue = null; // release the queue
					initInProgress = false;
					callbacks.onDeviceReady(bluetoothDevice);
				}
				// If so, we can continue with the task queue
				request = taskQueue.poll();
				if (request == null) {
					// Nothing to be done for now
					return;
				}
			}

			operationInProgress = true;
			boolean result = false;
			switch (request.type) {
				case CREATE_BOND: {
					result = internalCreateBond();
					break;
				}
				case READ: {
					result = internalReadCharacteristic(request.characteristic);
					break;
				}
				case WRITE: {
					final BluetoothGattCharacteristic characteristic = request.characteristic;
					characteristic.setValue(request.data);
					characteristic.setWriteType(request.writeType);
					result = internalWriteCharacteristic(characteristic);
					break;
				}
				case READ_DESCRIPTOR: {
					result = internalReadDescriptor(request.descriptor);
					break;
				}
				case WRITE_DESCRIPTOR: {
					final BluetoothGattDescriptor descriptor = request.descriptor;
					descriptor.setValue(request.data);
					result = internalWriteDescriptor(descriptor);
					break;
				}
				case ENABLE_NOTIFICATIONS: {
					result = internalEnableNotifications(request.characteristic);
					break;
				}
				case ENABLE_INDICATIONS: {
					result = internalEnableIndications(request.characteristic);
					break;
				}
				case READ_BATTERY_LEVEL: {
					result = internalReadBatteryLevel();
					break;
				}
				case ENABLE_BATTERY_LEVEL_NOTIFICATIONS: {
					result = internalSetBatteryNotifications(true);
					break;
				}
				case DISABLE_BATTERY_LEVEL_NOTIFICATIONS: {
					result = internalSetBatteryNotifications(false);
					break;
				}
				case ENABLE_SERVICE_CHANGED_INDICATIONS: {
					result = ensureServiceChangedEnabled();
					break;
				}
				case REQUEST_MTU: {
					result = internalRequestMtu(request.value);
					break;
				}
				case REQUEST_CONNECTION_PRIORITY: {
					if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
						connectionPriorityOperationInProgress = true;
						result = internalRequestConnectionPriority(request.value);
					} else {
						result = internalRequestConnectionPriority(request.value);
						// There is no callback for requestConnectionPriority(...) before Android Oreo.\
						// Let's give it some time to finish as the request is an asynchronous operation.
						if (result) {
							handler.postDelayed(() -> {
								operationInProgress = false;
								nextRequest();
							}, 100);
						}
					}
					break;
				}
			}
			// The result may be false if given characteristic or descriptor were not found on the device.
			// In that case, proceed with next operation and ignore the one that failed.
			if (!result) {
				connectionPriorityOperationInProgress = false;
				operationInProgress = false;
				nextRequest();
			}
		}

		/**
		 * Returns true if the characteristic is the Battery Level characteristic.
		 *
		 * @param characteristic the characteristic to be checked
		 * @return true if the characteristic is the Battery Level characteristic.
		 */
		private boolean isBatteryLevelCharacteristic(final BluetoothGattCharacteristic characteristic) {
			if (characteristic == null)
				return false;

			return BATTERY_LEVEL_CHARACTERISTIC.equals(characteristic.getUuid());
		}
	}
}