/*
 *   Copyright (c) 2019 Martijn van Welie
 *
 *   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 com.welie.blessed;

import android.Manifest;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.ParcelUuid;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import timber.log.Timber;

/**
 * Central class to connect and communicate with bluetooth peripherals.
 */
@SuppressWarnings({"SpellCheckingInspection", "unused", "WeakerAccess"})
public class BluetoothCentral {

    // Private constants
    private static final long SCAN_TIMEOUT = 180_000L;
    private static final int SCAN_RESTART_DELAY = 1000;
    private static final int MAX_CONNECTION_RETRIES = 1;
    private static final int MAX_CONNECTED_PERIPHERALS = 7;

    /**
     * Failed to start scan as BLE scan with the same settings is already started by the app.
     */
    public static final int SCAN_FAILED_ALREADY_STARTED = 1;

    /**
     * Failed to start scan as app cannot be registered.
     */
    public static final int SCAN_FAILED_APPLICATION_REGISTRATION_FAILED = 2;

    /**
     * Failed to start scan due an internal error
     */
    public static final int SCAN_FAILED_INTERNAL_ERROR = 3;

    /**
     * Failed to start power optimized scan as this feature is not supported.
     */
    public static final int SCAN_FAILED_FEATURE_UNSUPPORTED = 4;

    /**
     * Failed to start scan as it is out of hardware resources.
     */
    public static final int SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES = 5;

    /**
     * Failed to start scan as application tries to scan too frequently.
     */
    public static final int SCAN_FAILED_SCANNING_TOO_FREQUENTLY = 6;


    // Private variables
    private final Context context;
    private final Handler callBackHandler;
    private final BluetoothAdapter bluetoothAdapter;
    private BluetoothLeScanner bluetoothScanner;
    private BluetoothLeScanner autoConnectScanner;
    private final BluetoothCentralCallback bluetoothCentralCallback;
    private final Map<String, BluetoothPeripheral> connectedPeripherals = new ConcurrentHashMap<>();
    private final Map<String, BluetoothPeripheral> unconnectedPeripherals = new ConcurrentHashMap<>();
    private final List<String> reconnectPeripheralAddresses = new ArrayList<>();
    private final Map<String, BluetoothPeripheralCallback> reconnectCallbacks = new ConcurrentHashMap<>();
    private String[] scanPeripheralNames;
    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    private Runnable timeoutRunnable;
    private Runnable autoConnectRunnable;
    private final Object connectLock = new Object();
    private ScanCallback currentCallback;
    private List<ScanFilter> currentFilters;
    private ScanSettings scanSettings;
    private final ScanSettings autoConnectScanSettings;
    private final Map<String, Integer> connectionRetries = new ConcurrentHashMap<>();
    private boolean expectingBluetoothOffDisconnects = false;
    private Runnable disconnectRunnable;
    private final Map<String, String> pinCodes = new ConcurrentHashMap<>();

    //region Callbacks

    private final ScanCallback scanByNameCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, final ScanResult result) {
            synchronized (this) {
                String deviceName = result.getDevice().getName();
                if (deviceName == null) return;

                for (String name : scanPeripheralNames) {
                    if (deviceName.contains(name)) {
                        callBackHandler.post(new Runnable() {
                            @Override
                            public void run() {
                                if (isScanning()) {
                                    BluetoothPeripheral peripheral = new BluetoothPeripheral(context, result.getDevice(), internalCallback, null, callBackHandler);
                                    bluetoothCentralCallback.onDiscoveredPeripheral(peripheral, result);
                                }
                            }
                        });
                        return;
                    }
                }
            }
        }

        @Override
        public void onScanFailed(final int errorCode) {
            Timber.e("scan failed with error code %d (%s)", errorCode, scanErrorToString(errorCode));
            callBackHandler.post(new Runnable() {
                @Override
                public void run() {
                    bluetoothCentralCallback.onScanFailed(errorCode);
                }
            });
        }
    };

    private final ScanCallback scanByServiceUUIDCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, final ScanResult result) {
            synchronized (this) {
                callBackHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        if (isScanning()) {
                            BluetoothPeripheral peripheral = new BluetoothPeripheral(context, result.getDevice(), internalCallback, null, callBackHandler);
                            bluetoothCentralCallback.onDiscoveredPeripheral(peripheral, result);
                        }
                    }
                });
            }
        }

        @Override
        public void onScanFailed(final int errorCode) {
            Timber.e("scan failed with error code %d (%s)", errorCode, scanErrorToString(errorCode));
            callBackHandler.post(new Runnable() {
                @Override
                public void run() {
                    bluetoothCentralCallback.onScanFailed(errorCode);
                }
            });
        }
    };

    private final ScanCallback autoConnectScanCallback = new ScanCallback() {
        @Override
        public void onScanResult(int callbackType, ScanResult result) {
            synchronized (this) {
                if (!isAutoScanning()) return;

                Timber.d("peripheral with address '%s' found", result.getDevice().getAddress());
                stopAutoconnectScan();

                String deviceAddress = result.getDevice().getAddress();
                BluetoothPeripheral peripheral = unconnectedPeripherals.get(deviceAddress);
                BluetoothPeripheralCallback callback = reconnectCallbacks.get(deviceAddress);

                reconnectPeripheralAddresses.remove(deviceAddress);
                reconnectCallbacks.remove(deviceAddress);
                unconnectedPeripherals.remove(deviceAddress);

                connectPeripheral(peripheral, callback);

                if (reconnectPeripheralAddresses.size() > 0) {
                    scanForAutoConnectPeripherals();
                }
            }
        }

        @Override
        public void onScanFailed(final int errorCode) {
            Timber.e("scan failed with error code %d (%s)", errorCode, scanErrorToString(errorCode));
            callBackHandler.post(new Runnable() {
                @Override
                public void run() {
                    bluetoothCentralCallback.onScanFailed(errorCode);
                }
            });
        }
    };

    private final BluetoothPeripheral.InternalCallback internalCallback = new BluetoothPeripheral.InternalCallback() {

        @Override
        public void connected(final BluetoothPeripheral peripheral) {
            connectionRetries.remove(peripheral.getAddress());
            unconnectedPeripherals.remove(peripheral.getAddress());
            connectedPeripherals.put(peripheral.getAddress(), peripheral);
            if (connectedPeripherals.size() == MAX_CONNECTED_PERIPHERALS) {
                Timber.w("maximum amount (%d) of connected peripherals reached", MAX_CONNECTED_PERIPHERALS);
            }

            callBackHandler.post(new Runnable() {
                @Override
                public void run() {
                    bluetoothCentralCallback.onConnectedPeripheral(peripheral);
                }
            });
        }

        @Override
        public void connectFailed(final BluetoothPeripheral peripheral, final int status) {
            unconnectedPeripherals.remove(peripheral.getAddress());

            // Get the number of retries for this peripheral
            int nrRetries = 0;
            if (connectionRetries.get(peripheral.getAddress()) != null) {
                Integer retries = connectionRetries.get(peripheral.getAddress());
                if (retries != null) nrRetries = retries;
            }

            // Retry connection or conclude the connection has failed
            if (nrRetries < MAX_CONNECTION_RETRIES && status != BluetoothPeripheral.GATT_CONN_TIMEOUT) {
                Timber.i("retrying connection to '%s' (%s)", peripheral.getName(), peripheral.getAddress());
                nrRetries++;
                connectionRetries.put(peripheral.getAddress(), nrRetries);
                unconnectedPeripherals.put(peripheral.getAddress(), peripheral);

                // Retry with autoconnect
                peripheral.autoConnect();
            } else {
                Timber.i("connection to '%s' (%s) failed", peripheral.getName(), peripheral.getAddress());
                callBackHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        bluetoothCentralCallback.onConnectionFailed(peripheral, status);
                    }
                });
            }
        }

        @Override
        public void disconnected(final BluetoothPeripheral peripheral, final int status) {
            if (expectingBluetoothOffDisconnects) {
                cancelDisconnectionTimer();
                expectingBluetoothOffDisconnects = false;
            }

            connectedPeripherals.remove(peripheral.getAddress());
            unconnectedPeripherals.remove(peripheral.getAddress());

            callBackHandler.post(new Runnable() {
                @Override
                public void run() {
                    bluetoothCentralCallback.onDisconnectedPeripheral(peripheral, status);
                }
            });
        }

        @Override
        public String getPincode(BluetoothPeripheral device) {
            return pinCodes.get(device.getAddress());
        }
    };

    //endregion

    /**
     * Construct a new BluetoothCentral object
     *
     * @param context                  Android application environment.
     * @param bluetoothCentralCallback the callback to call for updates
     * @param handler                  Handler to use for callbacks.
     */
    public BluetoothCentral(Context context, BluetoothCentralCallback bluetoothCentralCallback, Handler handler) {
        if (context == null) {
            Timber.e("context is 'null', cannot create BluetoothCentral");
        }
        if (bluetoothCentralCallback == null) {
            Timber.e("callback is 'null', cannot create BluetoothCentral");
        }
        this.context = context;
        this.bluetoothCentralCallback = bluetoothCentralCallback;
        this.callBackHandler = (handler != null) ? handler : new Handler();
        this.bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            this.autoConnectScanSettings = new ScanSettings.Builder()
                    .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
                    .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
                    .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
                    .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
                    .setReportDelay(0L)
                    .build();
        } else {
            this.autoConnectScanSettings = new ScanSettings.Builder()
                    .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
                    .setReportDelay(0L)
                    .build();
        }
        setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY);

        // Register for broadcasts on BluetoothAdapter state change
        IntentFilter filter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED);
        if (context != null) {
            context.registerReceiver(adapterStateReceiver, filter);
        }
    }

    /**
     * Closes BluetoothCentral and cleans up internals. BluetoothCentral will not work anymore after this is called.
     */
    public void close() {
        unconnectedPeripherals.clear();
        connectedPeripherals.clear();
        reconnectCallbacks.clear();
        reconnectPeripheralAddresses.clear();
        if (context != null) {
            context.unregisterReceiver(adapterStateReceiver);
        }
    }

    /**
     * Set the default scanMode.
     *
     * <p>Must be ScanSettings.SCAN_MODE_LOW_POWER, ScanSettings.SCAN_MODE_LOW_LATENCY, ScanSettings.SCAN_MODE_BALANCED or ScanSettings.SCAN_MODE_OPPORTUNISTIC.
     * The default value is SCAN_MODE_LOW_LATENCY.
     *
     * @param scanMode the scanMode to set
     * @return true if a valid scanMode was provided, otherwise false
     */
    public boolean setScanMode(int scanMode) {
        if (scanMode == ScanSettings.SCAN_MODE_LOW_POWER ||
                scanMode == ScanSettings.SCAN_MODE_LOW_LATENCY ||
                scanMode == ScanSettings.SCAN_MODE_BALANCED ||
                scanMode == ScanSettings.SCAN_MODE_OPPORTUNISTIC) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                this.scanSettings = new ScanSettings.Builder()
                        .setScanMode(scanMode)
                        .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
                        .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
                        .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
                        .setReportDelay(0L)
                        .build();
            } else {
                this.scanSettings = new ScanSettings.Builder()
                        .setScanMode(scanMode)
                        .setReportDelay(0L)
                        .build();
            }
            return true;
        }
        return false;
    }

    private void startScan(List<ScanFilter> filters, ScanSettings scanSettings, ScanCallback scanCallback) {
        // Check is BLE is available, enabled and all permission granted
        if (!isBleReady()) return;

        // Make sure we are not already scanning, we only want one scan at the time
        if (isScanning()) {
            Timber.e("other scan still active, stopping scan");
            stopScan();
        }

        // Get a new scanner object
        if (bluetoothScanner == null) {
            bluetoothScanner = bluetoothAdapter.getBluetoothLeScanner();
        }

        // If get scanner was succesful, start the scan
        if (bluetoothScanner != null) {
            // Start the scanner
            setScanTimer();
            currentCallback = scanCallback;
            currentFilters = filters;
            bluetoothScanner.startScan(filters, scanSettings, scanCallback);
            Timber.i("scan started");
        } else {
            Timber.e("starting scan failed");
        }
    }

    /**
     * Scan for peripherals that advertise at least one of the specified service UUIDs.
     *
     * @param serviceUUIDs an array of service UUIDs
     */
    public void scanForPeripheralsWithServices(final UUID[] serviceUUIDs) {
        currentFilters = null;
        if (serviceUUIDs != null) {
            currentFilters = new ArrayList<>();
            for (UUID serviceUUID : serviceUUIDs) {
                ScanFilter filter = new ScanFilter.Builder()
                        .setServiceUuid(new ParcelUuid(serviceUUID))
                        .build();
                currentFilters.add(filter);
            }
        }

        startScan(currentFilters, scanSettings, scanByServiceUUIDCallback);
    }

    /**
     * Scan for peripherals with advertisement names containing any of the specified peripheral names.
     *
     * <p>Substring matching is used so only a partial peripheral names has to be supplied.
     *
     * @param peripheralNames array of partial peripheral names
     */
    public void scanForPeripheralsWithNames(final String[] peripheralNames) {
        // Start the scanner with no filter because we'll do the filtering ourselves
        scanPeripheralNames = peripheralNames;
        startScan(null, scanSettings, scanByNameCallback);
    }

    /**
     * Scan for peripherals that have any of the specified peripheral mac addresses.
     *
     * @param peripheralAddresses array of peripheral mac addresses to scan for
     */
    public void scanForPeripheralsWithAddresses(final String[] peripheralAddresses) {
        List<ScanFilter> filters = null;
        if (peripheralAddresses != null) {
            filters = new ArrayList<>();
            for (String address : peripheralAddresses) {
                if (BluetoothAdapter.checkBluetoothAddress(address)) {
                    ScanFilter filter = new ScanFilter.Builder()
                            .setDeviceAddress(address)
                            .build();
                    filters.add(filter);
                } else {
                    Timber.e("%s is not a valid address. Make sure all alphabetic characters are uppercase.", address);
                }
            }
        }

        startScan(filters, scanSettings, scanByServiceUUIDCallback);
    }

    /**
     * Scan for any peripheral that is advertising.
     */
    public void scanForPeripherals() {
        startScan(null, scanSettings, scanByServiceUUIDCallback);
    }

    /**
     * Scan for peripherals that need to be autoconnected but are not cached
     */
    private void scanForAutoConnectPeripherals() {
        // Check is BLE is available, enabled and all permission granted
        if (!isBleReady()) return;

        // Stop previous autoconnect scans if any
        if (autoConnectScanner != null) {
            stopAutoconnectScan();
        }

        // Start the scanner
        autoConnectScanner = bluetoothAdapter.getBluetoothLeScanner();
        if (autoConnectScanner != null) {
            List<ScanFilter> filters;
            filters = new ArrayList<>();
            for (String address : reconnectPeripheralAddresses) {
                ScanFilter filter = new ScanFilter.Builder()
                        .setDeviceAddress(address)
                        .build();
                filters.add(filter);
            }

            // Start the scanner
            autoConnectScanner.startScan(filters, autoConnectScanSettings, autoConnectScanCallback);
            Timber.d("started scanning to autoconnect peripherals (" + reconnectPeripheralAddresses.size() + ")");
            setAutoConnectTimer();
        } else {
            Timber.e("starting autoconnect scan failed");
        }
    }

    private void stopAutoconnectScan() {
        cancelAutoConnectTimer();
        if (autoConnectScanner != null) {
            autoConnectScanner.stopScan(autoConnectScanCallback);
            autoConnectScanner = null;
            Timber.i("autoscan stopped");
        }
    }

    private boolean isAutoScanning() {
        return autoConnectScanner != null;
    }

    /**
     * Stop scanning for peripherals.
     */
    public void stopScan() {
        cancelTimeoutTimer();
        if (isScanning()) {
            bluetoothScanner.stopScan(currentCallback);
            Timber.i("scan stopped");
        } else {
            Timber.i("no scan to stop because no scan is running");
        }
        currentCallback = null;
        currentFilters = null;
    }

    /**
     * Check if a scanning is active
     *
     * @return true if a scan is active, otherwise false
     */
    public boolean isScanning() {
        return (bluetoothScanner != null && currentCallback != null);
    }

    /**
     * Connect to a known peripheral immediately. The peripheral must have been found by scanning for this call to succeed. This method will time out in max 30 seconds on most phones and in 5 seconds on Samsung phones.
     * If the peripheral is already connected, no connection attempt will be made. This method is asynchronous and there can be only one outstanding connect.
     *
     * @param peripheral BLE peripheral to connect with
     */
    public void connectPeripheral(BluetoothPeripheral peripheral, BluetoothPeripheralCallback peripheralCallback) {
        synchronized (connectLock) {
            // Make sure peripheral is valid
            if (peripheral == null) {
                Timber.e("no valid peripheral specified, aborting connection");
                return;
            }

            // Make sure peripheral callback is valid
            if (peripheralCallback == null) {
                Timber.e("no valid peripheral callback specified, aborting connection");
                return;
            }

            // Check if we are already connected to this peripheral
            if (connectedPeripherals.containsKey(peripheral.getAddress())) {
                Timber.w("already connected to %s'", peripheral.getAddress());
                return;
            }

            // Check if we already have an outstanding connection request for this peripheral
            if (unconnectedPeripherals.containsKey(peripheral.getAddress())) {
                Timber.w("already connecting to %s'", peripheral.getAddress());
                return;
            }

            // Check if the peripheral is cached or not. If not, issue a warning
            int deviceType = peripheral.getType();
            if (deviceType == BluetoothDevice.DEVICE_TYPE_UNKNOWN) {
                // The peripheral is not cached so connection is likely to fail
                Timber.w("peripheral with address '%s' is not in the Bluetooth cache, hence connection may fail", peripheral.getAddress());
            }

            // It is all looking good! Set the callback and prepare to connect
            peripheral.setPeripheralCallback(peripheralCallback);
            unconnectedPeripherals.put(peripheral.getAddress(), peripheral);

            // Now connect
            peripheral.connect();
        }
    }

    /**
     * Automatically connect to a peripheral when it is advertising. It is not necessary to scan for the peripheral first. This call is asynchronous and will not time out.
     *
     * @param peripheral the peripheral
     */
    public void autoConnectPeripheral(BluetoothPeripheral peripheral, BluetoothPeripheralCallback peripheralCallback) {
        synchronized (connectLock) {
            // Make sure peripheral is valid
            if (peripheral == null) {
                Timber.e("no valid peripheral specified, aborting connection");
                return;
            }

            // Make sure peripheral callback is valid
            if (peripheralCallback == null) {
                Timber.e("no valid peripheral callback specified, aborting connection");
                return;
            }
            
            // Check if we are already connected to this peripheral
            if (connectedPeripherals.containsKey(peripheral.getAddress())) {
                Timber.w("already connected to %s'", peripheral.getAddress());
                return;
            }

            // Check if we are not already asking this peripheral for data
            if (unconnectedPeripherals.get(peripheral.getAddress()) != null) {
                Timber.w("already issued autoconnect for '%s' ", peripheral.getAddress());
                return;
            }

            // Check if the peripheral is cached or not
            int deviceType = peripheral.getType();
            if (deviceType == BluetoothDevice.DEVICE_TYPE_UNKNOWN) {
                // The peripheral is not cached so we cannot autoconnect
                Timber.d("peripheral with address '%s' not in Bluetooth cache, autoconnecting by scanning", peripheral.getAddress());
                unconnectedPeripherals.put(peripheral.getAddress(), peripheral);
                autoConnectPeripheralByScan(peripheral.getAddress(), peripheralCallback);
                return;
            }

            // Check if the peripheral supports BLE
            if (!(deviceType == BluetoothDevice.DEVICE_TYPE_LE || deviceType == BluetoothDevice.DEVICE_TYPE_DUAL)) {
                // This device does not support Bluetooth LE, so we cannot connect
                Timber.e("peripheral does not support Bluetooth LE");
                return;
            }

            // It is all looking good! Set the callback and prepare for autoconnect
            peripheral.setPeripheralCallback(peripheralCallback);
            unconnectedPeripherals.put(peripheral.getAddress(), peripheral);

            // Autoconnect to this peripheral
            peripheral.autoConnect();
        }
    }

    private void autoConnectPeripheralByScan(String peripheralAddress, BluetoothPeripheralCallback peripheralCallback) {
        // Check if this peripheral is already on the list or not
        if (reconnectPeripheralAddresses.contains(peripheralAddress)) {
            Timber.w("peripheral already on list for reconnection");
            return;
        }

        reconnectPeripheralAddresses.add(peripheralAddress);
        reconnectCallbacks.put(peripheralAddress, peripheralCallback);
        scanForAutoConnectPeripherals();
    }

    /**
     * Cancel an active or pending connection for a peripheral.
     *
     * @param peripheral the peripheral
     */
    public void cancelConnection(final BluetoothPeripheral peripheral) {
        // Check if peripheral is valid
        if (peripheral == null) {
            Timber.e("cannot cancel connection, peripheral is null");
            return;
        }

        // First check if we are doing a reconnection scan for this peripheral
        String peripheralAddress = peripheral.getAddress();
        if (reconnectPeripheralAddresses.contains(peripheralAddress)) {
            // Clean up first
            reconnectPeripheralAddresses.remove(peripheralAddress);
            reconnectCallbacks.remove(peripheralAddress);
            stopAutoconnectScan();
            Timber.d("cancelling autoconnect for %s", peripheralAddress);

            // If there are any devices left, restart the reconnection scan
            if (reconnectPeripheralAddresses.size() > 0) {
                scanForAutoConnectPeripherals();
            }

            callBackHandler.post(new Runnable() {
                @Override
                public void run() {
                    bluetoothCentralCallback.onDisconnectedPeripheral(peripheral, BluetoothPeripheral.GATT_SUCCESS);
                }
            });

            return;
        }

        // Check if it is an unconnected peripheral
        if (unconnectedPeripherals.containsKey(peripheralAddress)) {
            BluetoothPeripheral unconnectedPeripheral = unconnectedPeripherals.get(peripheralAddress);
            if (unconnectedPeripheral != null) {
                unconnectedPeripheral.cancelConnection();
            }
            return;
        }

        // Check if this is a connected peripheral
        if (connectedPeripherals.containsKey(peripheralAddress)) {
            BluetoothPeripheral connectedPeripheral = connectedPeripherals.get(peripheralAddress);
            if (connectedPeripheral != null) {
                connectedPeripheral.cancelConnection();
            }
        } else {
            Timber.e("cannot cancel connection to unknown peripheral %s", peripheralAddress);
        }
    }

    /**
     * Autoconnect to a batch of peripherals.
     * <p>
     * Use this function to autoConnect to a batch of peripherals, instead of calling autoConnect on each of them.
     * Calling autoConnect on many peripherals may cause Android scanning limits to kick in, which is avoided by using autoConnectPeripheralsBatch.
     *
     * @param batch the map of peripherals and their callbacks to autoconnect to
     */
    public void autoConnectPeripheralsBatch(Map<BluetoothPeripheral, BluetoothPeripheralCallback> batch) {
        Map<BluetoothPeripheral, BluetoothPeripheralCallback> uncachedPeripherals = new HashMap<>();
        Map<BluetoothPeripheral, BluetoothPeripheralCallback> cachedPeripherals = new HashMap<>();

        // Split the list in cached and uncached peripherals
        for (BluetoothPeripheral peripheral : batch.keySet()) {
            if (peripheral.getType() == BluetoothDevice.DEVICE_TYPE_UNKNOWN) {
                uncachedPeripherals.put(peripheral, batch.get(peripheral));
            } else {
                cachedPeripherals.put(peripheral, batch.get(peripheral));
            }
        }

        // Issue autoconnect for cached peripherals
        for (BluetoothPeripheral peripheral : cachedPeripherals.keySet()) {
            autoConnectPeripheral(peripheral, cachedPeripherals.get(peripheral));
        }

        // Add uncached peripherals to list of peripherals to scan for
        if (!uncachedPeripherals.isEmpty()) {
            for (BluetoothPeripheral peripheral : uncachedPeripherals.keySet()) {
                String peripheralAddress = peripheral.getAddress();

                // Check if this peripheral is already on the list or not
                if (reconnectPeripheralAddresses.contains(peripheralAddress)) {
                    Timber.w("peripheral already on list for reconnection");
                } else {
                    reconnectPeripheralAddresses.add(peripheralAddress);
                }
                reconnectCallbacks.put(peripheralAddress, uncachedPeripherals.get(peripheral));
                unconnectedPeripherals.put(peripheral.getAddress(), peripheral);
            }
            scanForAutoConnectPeripherals();
        }
    }

    /**
     * Get a peripheral object matching the specified mac address.
     *
     * @param peripheralAddress mac address
     * @return a BluetoothPeripheral object matching the specified mac address or null if it was not found
     */
    public BluetoothPeripheral getPeripheral(String peripheralAddress) {
        if (!BluetoothAdapter.checkBluetoothAddress(peripheralAddress)) {
            Timber.e("%s is not a valid address. Make sure all alphabetic characters are uppercase.", peripheralAddress);
            return null;
        }

        if (connectedPeripherals.containsKey(peripheralAddress)) {
            return connectedPeripherals.get(peripheralAddress);
        } else if (unconnectedPeripherals.containsKey(peripheralAddress)) {
            return unconnectedPeripherals.get(peripheralAddress);
        } else {
            return new BluetoothPeripheral(context, bluetoothAdapter.getRemoteDevice(peripheralAddress), internalCallback, null, callBackHandler);
        }
    }

    /**
     * Get the list of connected peripherals.
     *
     * @return list of connected peripherals
     */
    public List<BluetoothPeripheral> getConnectedPeripherals() {
        return new ArrayList<>(connectedPeripherals.values());
    }

    private boolean isBleReady() {
        if (isBleSupported()) {
            if (isBluetoothEnabled()) {
                return permissionsGranted();
            }
        }
        return false;
    }

    private boolean isBleSupported() {
        if (bluetoothAdapter != null && context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
            return true;
        }

        Timber.e("BLE not supported");
        return false;
    }

    /**
     * Check if Bluetooth is enabled
     * @return true is Bluetooth is enabled, otherwise false
     */
    public boolean isBluetoothEnabled() {
        if (bluetoothAdapter.isEnabled()) {
            return true;
        }
        Timber.e("Bluetooth disabled");
        return false;
    }

    private boolean permissionsGranted() {
        int targetSdkVersion = context.getApplicationInfo().targetSdkVersion;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && targetSdkVersion >= Build.VERSION_CODES.Q) {
            if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                Timber.e("no ACCESS_FINE_LOCATION permission, cannot scan");
                return false;
            } else return true;
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                Timber.e("no ACCESS_COARSE_LOCATION permission, cannot scan");
                return false;
            } else return true;
        } else {
            return true;
        }
    }

    /**
     * Set scan timeout timer, timeout time is {@code SCAN_TIMEOUT}.
     * If timeout is executed the scan is stopped and automatically restarted. This is done to avoid Android 9 scan restrictions
     */
    private void setScanTimer() {
        cancelTimeoutTimer();

        timeoutRunnable = new Runnable() {
            @Override
            public void run() {
                Timber.d("scanning timeout, restarting scan");
                final ScanCallback callback = currentCallback;
                final List<ScanFilter> filters = currentFilters;
                stopScan();

                // Restart the scan and timer
                callBackHandler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        startScan(filters, scanSettings, callback);
                    }
                }, SCAN_RESTART_DELAY);
            }
        };

        mainHandler.postDelayed(timeoutRunnable, SCAN_TIMEOUT);
    }

    /**
     * Cancel the scan timeout timer
     */
    private void cancelTimeoutTimer() {
        if (timeoutRunnable != null) {
            mainHandler.removeCallbacks(timeoutRunnable);
            timeoutRunnable = null;
        }
    }

    /**
     * Set scan timeout timer, timeout time is {@code SCAN_TIMEOUT}.
     * If timeout is executed the scan is stopped and automatically restarted. This is done to avoid Android 9 scan restrictions
     */
    private void setAutoConnectTimer() {
        cancelAutoConnectTimer();
        autoConnectRunnable = new Runnable() {
            @Override
            public void run() {
                Timber.d("autoconnect scan timeout, restarting scan");

                // Stop previous autoconnect scans if any
                if (autoConnectScanner != null) {
                    autoConnectScanner.stopScan(autoConnectScanCallback);
                    autoConnectScanner = null;
                }

                // Restart the auto connect scan and timer
                mainHandler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        scanForAutoConnectPeripherals();
                    }
                }, SCAN_RESTART_DELAY);
            }
        };

        mainHandler.postDelayed(autoConnectRunnable, SCAN_TIMEOUT);
    }

    /**
     * Cancel the scan timeout timer
     */
    private void cancelAutoConnectTimer() {
        if (autoConnectRunnable != null) {
            mainHandler.removeCallbacks(autoConnectRunnable);
            autoConnectRunnable = null;
        }
    }

    /**
     * Set a fixed PIN code for a peripheral that asks fir a PIN code during bonding.
     * <p>
     * This PIN code will be used to programmatically bond with the peripheral when it asks for a PIN code.
     * Note that this only works for devices with a fixed PIN code.
     *
     * @param peripheralAddress the address of the peripheral
     * @param pin               the 6 digit PIN code as a string, e.g. "123456"
     * @return true if the pin code and peripheral address are valid and stored internally
     */
    public boolean setPinCodeForPeripheral(String peripheralAddress, String pin) {
        if (!BluetoothAdapter.checkBluetoothAddress(peripheralAddress)) {
            Timber.e("%s is not a valid address. Make sure all alphabetic characters are uppercase.", peripheralAddress);
            return false;
        }

        if (pin == null) {
            Timber.e("pin code is null");
            return false;
        }

        if (pin.length() != 6) {
            Timber.e("%s is not 6 digits long", pin);
            return false;
        }

        pinCodes.put(peripheralAddress, pin);
        return true;
    }

    /**
     * Remove bond for a peripheral.
     *
     * @param peripheralAddress the address of the peripheral
     * @return true if the peripheral was succesfully unpaired or it wasn't paired, false if it was paired and removing it failed
     */
    public boolean removeBond(String peripheralAddress) {
        boolean result;
        BluetoothDevice peripheralToUnBond = null;

        // Get the set of bonded devices
        Set<BluetoothDevice> bondedDevices = bluetoothAdapter.getBondedDevices();

        // See if the device is bonded
        if (bondedDevices.size() > 0) {
            for (BluetoothDevice device : bondedDevices) {
                if (device.getAddress().equals(peripheralAddress)) {
                    peripheralToUnBond = device;
                }
            }
        } else {
            return true;
        }

        // Try to remove the bond
        if (peripheralToUnBond != null) {
            try {
                Method method = peripheralToUnBond.getClass().getMethod("removeBond", (Class[]) null);
                result = (boolean) method.invoke(peripheralToUnBond, (Object[]) null);
                if (result) {
                    Timber.i("Succesfully removed bond for '%s'", peripheralToUnBond.getName());
                }
                return result;
            } catch (Exception e) {
                Timber.i("could not remove bond");
                e.printStackTrace();
                return false;
            }
        } else {
            return true;
        }
    }

    /**
     * Make the pairing popup appear in the foreground by doing a 1 sec discovery.
     * <p>
     * If the pairing popup is shown within 60 seconds, it will be shown in the foreground.
     */
    public void startPairingPopupHack() {
        // Check if we are on a Samsung device because those don't need the hack
        String manufacturer = Build.MANUFACTURER;
        if (!manufacturer.equals("samsung")) {
            bluetoothAdapter.startDiscovery();

            callBackHandler.postDelayed(new Runnable() {
                @Override
                public void run() {
                    Timber.d("popup hack completed");
                    bluetoothAdapter.cancelDiscovery();
                }
            }, 1000);
        }
    }


    /**
     * Some phones, like Google/Pixel phones, don't automatically disconnect devices so this method does it manually
     */
    private void cancelAllConnectionsWhenBluetoothOff() {
        Timber.d("disconnect all peripherals because bluetooth is off");
        // Call cancelConnection for connected peripherals
        for (final BluetoothPeripheral peripheral : connectedPeripherals.values()) {
            peripheral.disconnectWhenBluetoothOff();
        }
        connectedPeripherals.clear();

        // Call cancelConnection for unconnected peripherals
        for (final BluetoothPeripheral peripheral : unconnectedPeripherals.values()) {
            peripheral.disconnectWhenBluetoothOff();
        }
        unconnectedPeripherals.clear();

        // Clean up autoconnect by scanning information
        reconnectPeripheralAddresses.clear();
        reconnectCallbacks.clear();
    }

    /**
     * Timer to determine if manual disconnection in case of bluetooth off is needed
     */
    private void startDisconnectionTimer() {
        cancelDisconnectionTimer();
        disconnectRunnable = new Runnable() {
            @Override
            public void run() {
                Timber.e("bluetooth turned off but no automatic disconnects happening, so doing it ourselves");
                cancelAllConnectionsWhenBluetoothOff();
                disconnectRunnable = null;
            }
        };

        mainHandler.postDelayed(disconnectRunnable, 1000);
    }

    /**
     * Cancel timer for bluetooth off disconnects
     */
    private void cancelDisconnectionTimer() {
        if (disconnectRunnable != null) {
            mainHandler.removeCallbacks(disconnectRunnable);
            disconnectRunnable = null;
        }
    }

    private final BroadcastReceiver adapterStateReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            final String action = intent.getAction();
            if (action == null) return;

            if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) {
                final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR);
                callBackHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        bluetoothCentralCallback.onBluetoothAdapterStateChanged(state);
                    }
                });

                handleAdapterState(state);
            }
        }
    };

    private void handleAdapterState(int state) {
        switch (state) {
            case BluetoothAdapter.STATE_OFF:
                // Check if there are any connected peripherals or connections in progress
                if (connectedPeripherals.size() > 0 || unconnectedPeripherals.size() > 0) {
                    // See if they are automatically disconnect
                    expectingBluetoothOffDisconnects = true;
                    startDisconnectionTimer();
                }
                Timber.d("bluetooth turned off");
                break;
            case BluetoothAdapter.STATE_TURNING_OFF:
                expectingBluetoothOffDisconnects = true;

                // Stop all scans so that we are back in a clean state
                // Note that we can't call stopScan if the adapter is off
                cancelTimeoutTimer();
                cancelAutoConnectTimer();
                currentCallback = null;
                currentFilters = null;
                autoConnectScanner = null;
                Timber.d("bluetooth turning off");
                break;
            case BluetoothAdapter.STATE_ON:
                expectingBluetoothOffDisconnects = false;
                Timber.d("bluetooth turned on");
                break;
            case BluetoothAdapter.STATE_TURNING_ON:
                expectingBluetoothOffDisconnects = false;
                Timber.d("bluetooth turning on");
                break;
        }
    }

    private String scanErrorToString(final int errorCode) {
        switch (errorCode) {
            case SCAN_FAILED_ALREADY_STARTED:
                return "ALREADY STARTED";
            case SCAN_FAILED_APPLICATION_REGISTRATION_FAILED:
                return "APPLICATION REGISTRATION FAILED";
            case SCAN_FAILED_INTERNAL_ERROR:
                return "INTERNAL ERROR";
            case SCAN_FAILED_FEATURE_UNSUPPORTED:
                return "FEATURE UNSUPPORTED";
            case SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES:
                return "OUT OF HARDWARE RESOURCES";
            case SCAN_FAILED_SCANNING_TOO_FREQUENTLY:
                return "SCANNING TOO FREQUENTLY";
            default:
                return "UNKNOWN";
        }
    }
}