/*
 * Copyright 2019 Fitbit, Inc. All rights reserved.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

package com.fitbit.bluetooth.fbgatt;

import com.fitbit.bluetooth.fbgatt.exception.AddingServiceOnStartException;
import com.fitbit.bluetooth.fbgatt.exception.AlreadyStartedException;
import com.fitbit.bluetooth.fbgatt.exception.BitGattStartException;
import com.fitbit.bluetooth.fbgatt.exception.BluetoothNotEnabledException;
import com.fitbit.bluetooth.fbgatt.exception.MissingGattServerErrorException;
import com.fitbit.bluetooth.fbgatt.exception.NoFiltersSetException;
import com.fitbit.bluetooth.fbgatt.logging.BitgattDebugTree;
import com.fitbit.bluetooth.fbgatt.logging.BitgattReleaseTree;
import com.fitbit.bluetooth.fbgatt.strategies.BluetoothOffClearGattServerStrategy;
import com.fitbit.bluetooth.fbgatt.strategies.Strategy;
import com.fitbit.bluetooth.fbgatt.tx.AddGattServerServiceTransaction;
import com.fitbit.bluetooth.fbgatt.tx.ClearServerServicesTransaction;
import com.fitbit.bluetooth.fbgatt.tx.GattConnectTransaction;
import com.fitbit.bluetooth.fbgatt.util.LooperWatchdog;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.PendingIntent;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattServer;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanRecord;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.ParcelUuid;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import timber.log.Timber;

/**
 * The gatt interface, will pass a connection object to a caller with a reference to this instance
 * so that they can communicate with a given device.
 * <p>
 * While the peripheral manager will be notified of new connections and disconnections, the actual
 * connection management will be handled here, by connection management we mean the caching of the
 * connection primarily as well as retiring unused connections.
 * <p>
 * The gatt server will by default have only the {@link GattServerCallback} listening for events
 * to make sure that your peripherals that are aggressive about discovering services and firing
 * off read requests don't disconnect because the services aren't started, we will respond with
 * gatt error until the services are ready.
 * <p>
 * Created by iowens on 10/17/17.
 */

public class FitbitGatt implements PeripheralScanner.TrackerScannerListener, BluetoothRadioStatusListener.BluetoothOnListener {

    static final long MAX_TTL = TimeUnit.HOURS.toMillis(1);
    private static final long GATT_SERVER_START_FAILURE_RETRY_INTERVAL = 500;
    /*
     * The Fitbit gatt instance, this holds the context, lint is rightfully complaining about
     * leaking the context.
     */
    @SuppressLint("StaticFieldLeak")
    private static volatile FitbitGatt ourInstance;
    private static final int OPEN_GATT_SERVER_RETRY_COUNT = 3;

    private final ConcurrentHashMap<FitbitBluetoothDevice, GattConnection> connectionMap = new ConcurrentHashMap<>();
    private static final long CLEANUP_INTERVAL = TimeUnit.MINUTES.toMillis(5);
    // this is only used on init
    private CopyOnWriteArrayList<FitbitGattCallback> overallGattEventListeners;

    private BluetoothGattServer gattServer;
    // this is only used on init
    private final CopyOnWriteArrayList<BluetoothGattService> servicesToAdd = new CopyOnWriteArrayList<>();
    @Nullable
    private GattServerConnection serverConnection;
    private GattServerCallback serverCallback;
    private GattClientCallback clientCallback;
    private @Nullable
    PeripheralScanner peripheralScanner;
    private @NonNull
    AlwaysConnectedScanner alwaysConnectedScanner;
    @VisibleForTesting
    LowEnergyAclListener aclListener;
    private @Nullable
    Context appContext;
    @VisibleForTesting
    AtomicBoolean isInitialized = new AtomicBoolean(false);
    //Tracks that we initialized gatt server and should be turned back on automatically on bluetooth toggle
    private AtomicBoolean isGattServerStarted = new AtomicBoolean(false);
    //used to track if we are starting the server already
    private AtomicBoolean isGattServerStarting = new AtomicBoolean(false);
    //Tracks that we initialized gatt client and we should run anything in cases where bluetooth gets toggled on/off
    private AtomicBoolean isGattClientStarted = new AtomicBoolean(false);
    private Handler connectionCleanup;
    @Nullable
    private LooperWatchdog asyncOperationThreadWatchdog;
    // this should be max priority so as to not affect performance
    private HandlerThread fitbitGattAsyncOperationThread = new HandlerThread("FitbitGatt Async Operation Thread", Thread.MAX_PRIORITY);
    private Handler fitbitGattAsyncOperationHandler;
    private BluetoothRadioStatusListener radioStatusListener;
    @VisibleForTesting
    volatile boolean isBluetoothOn;
    private volatile boolean slowLoggingEnabled = false;

    private BitGattDependencyProvider dependencyProvider = new BitGattDependencyProvider();

    /**
     * Will get the instance of the singleton FitbitGatt manager class
     * @return The instance of FitbitGatt
     */
    public static FitbitGatt getInstance() {
        if(ourInstance == null) {
            synchronized (FitbitGatt.class) {
                if (ourInstance == null) {
                    ourInstance = new FitbitGatt();
                    ourInstance.setup();
                }
            }
        }
        return ourInstance;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    public static void setInstance(@Nullable FitbitGatt gatt) {
        ourInstance = gatt;
    }

    private void setup(){
        // only add a custom logger if the implementer isn't using Timber, if they are using Timber
        // let them deal with it, just make sure your variants set BuildConfig.DEBUG correctly
        if (Timber.treeCount() == 0) {
            if (BuildConfig.DEBUG) {
                Timber.plant(new BitgattDebugTree());
            } else {
                Timber.plant(new BitgattReleaseTree());
            }
        }
        ourInstance.overallGattEventListeners = new CopyOnWriteArrayList<>();
        // we will default to one expected device and that it should not looking
        ourInstance.alwaysConnectedScanner = new AlwaysConnectedScanner(1, false, Looper.getMainLooper());
        ourInstance.fitbitGattAsyncOperationThread.start();
        ourInstance.fitbitGattAsyncOperationHandler = new Handler(ourInstance.fitbitGattAsyncOperationThread.getLooper());
        // we need to make sure that this thread is alive and responsive or our gatt
        // flow will stop and we won't be able to tell
        ourInstance.asyncOperationThreadWatchdog = new LooperWatchdog(ourInstance.fitbitGattAsyncOperationThread.getLooper());
    }

    @VisibleForTesting
    @SuppressWarnings("WeakerAccess") // API Method
    public synchronized void setGattServerConnection(GattServerConnection gattServer) {
        this.serverConnection = gattServer;
        for (FitbitGattCallback callback : overallGattEventListeners) {
            callback.onGattServerStarted(serverConnection);
        }
    }

    @SuppressWarnings("WeakerAccess") // API Method
    public synchronized boolean isBluetoothOn() {
        if (appContext == null) {
            Timber.w("Bitgatt must not be started yet, so as far as we know BT is off.");
            return false;
        }
        BluetoothAdapter adapter = dependencyProvider.getNewGattUtils().getBluetoothAdapter(appContext);
        if (adapter == null || !adapter.isEnabled()) {
            if (isBluetoothOn) {
                isBluetoothOn = false;
            }
        } else {
            if (!isBluetoothOn) {
                isBluetoothOn = true;
            }
        }
        return isBluetoothOn;
    }

    /**
     * Will allow access to the gatt server callback handler thread
     * @return The gatt server handler thread
     */

    public HandlerThread getFitbitGattAsyncOperationThread() {
        return fitbitGattAsyncOperationThread;
    }

    /**
     * Interface for use in opening gatt server
     */

    @VisibleForTesting
    interface OpenGattServerCallback {
        /**
         * The gatt server status is resolved to started or not
         *
         * @param started true if the gatt server started, false if not
         */
        void onGattServerStatus(boolean started);
    }

    /**
     * Used to communicate async errors
     */
    private interface StartErrorCallback {
        public void onError(BitGattStartException error);
    }

    /**
     * Used to communicate to subscribers about changes in the global state of the gatt library
     * as well as when peripherals are ready to be used.
     */

    public interface FitbitGattCallback {

        /**
         * An recently discovered bluetooth peripheral has been detected as the result of a scan, to prevent errors
         * you should check the peripheral's connection state because this could reuse the connection
         *
         * @param connection The connection that we have created as the result of a scan result
         */
        void onBluetoothPeripheralDiscovered(GattConnection connection);

        /**
         * A bluetooth peripheral has disconnected with the given connection
         *
         * @param connection The connection in the map of the disconnected peripheral
         */
        void onBluetoothPeripheralDisconnected(GattConnection connection);

        /**
         * Will notify if a scan has been started
         */
        void onScanStarted();

        /**
         * Will notify of scanner stop
         */
        void onScanStopped();

        /**
         * This will get called when we call {@link FitbitGatt#initializeScanner(Context)}, {@link FitbitGatt#startPeriodicalScannerWithFilters(Context, List)} ()} and
         * an error occurs
         *
         * @param error
         */
        void onScannerInitError(BitGattStartException error);

        /**
         * Will notify of pending intent scan stop
         */
        void onPendingIntentScanStopped();

        /**
         * Will notify of pending intent scan started
         */
        void onPendingIntentScanStarted();

        /**
         * In order to make sure that consumers of the bitgatt library only react to BT off
         * we will want for them to utilize the bt off / on as tracked by bitgatt and not
         * implement their own broadcast receiver.
         */
        @MainThread
        void onBluetoothOff();

        /**
         * In order to make sure that consumers of the bitgatt library only react to BT off
         * we will want for them to utilize the bt off / on as tracked by bitgatt and not
         * implement their own broadcast receiver.
         */
        @MainThread
        void onBluetoothOn();

        /**
         * Some phones may not deliver this, older ones especially may not, be aware that the code
         * in here may not be executed, however you can execute code that you want to have happen
         * before the radio turns on.
         */
        @MainThread
        void onBluetoothTurningOn();

        /**
         * Some phones deliver this, older ones may not, be aware that code inside of here may not
         * be executed, however you can execute code that you want to have happen
         * before the radio turns off
         */
        @MainThread
        void onBluetoothTurningOff();

        /**
         * Called when gatt server has started successfully
         *
         * @param serverConnection the {@link GattServerTransaction} that has been created
         */
        void onGattServerStarted(GattServerConnection serverConnection);

        /**
         * Called when gatt server has not been able to start
         *
         * @param error the error encountered
         */
        void onGattServerStartError(BitGattStartException error);

        /**
         * Called when client started
         */
        void onGattClientStarted();

        /**
         * Called when gatt client has not been able to start
         *
         * @param error the error encountered
         */
        void onGattClientStartError(BitGattStartException error);
    }

    @VisibleForTesting
    FitbitGatt() {
        // empty so that this class can be mocked
        // setup is done internal to getInstance
    }

    //Allows us to inject in FitbitGatt dependencies for testing
    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    FitbitGatt(AlwaysConnectedScanner alwaysConnectedScanner, Handler fitbitGattAsyncOperationHandler, Handler connectionCleanup, LooperWatchdog watchDog) {
        this.overallGattEventListeners = new CopyOnWriteArrayList<>();
        this.alwaysConnectedScanner = alwaysConnectedScanner;
        this.fitbitGattAsyncOperationHandler = fitbitGattAsyncOperationHandler;
        this.connectionCleanup = connectionCleanup;
        this.asyncOperationThreadWatchdog = watchDog;
    }

    public void registerGattEventListener(FitbitGattCallback callback) {
        if (!overallGattEventListeners.contains(callback)) {
            overallGattEventListeners.add(callback);
        }
    }

    public void unregisterGattEventListener(FitbitGattCallback callback) {
        overallGattEventListeners.remove(callback);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    @SuppressWarnings({"unused", "WeakerAccess"})
        // API Method
    List<GattClientListener> getAllGattClientListeners() {
        return getClientCallback().getGattClientListeners();
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    void unregisterAllGattEventListeners() {
        overallGattEventListeners.clear();
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setStarted() {
        Timber.i("Initalization complete, internalInitialize finished");
        boolean success = isInitialized.compareAndSet(false, true);
        if (!success) {
            Timber.w("There was a problem updating the started state, are you starting from two threads?");
        }
    }

    /**
     * Will start a high-priority scan, if there is already a scan in progress this call will cancel
     * the in-progress scan and start a new one at a high-duty cycle, please use this sparingly for
     * a couple of reasons:
     * 1) The concept of bitgatt is that you can rely on the system to find devices that match the
     * filter criteria.  This type of scan should only be necessary if you know that the device
     * is disconnected, and you suspect that a connection is not already in the cache.  Please use
     * the various background scanning APIs instead if your goal is to remain connected.
     * 2) This type of scan is very expensive power-wise for the phone and should not be used
     * constantly, please use the {@link FitbitGatt#startPeriodicScan(Context)} or {@link FitbitGatt#startBackgroundScan(Context, Intent, List)}
     * to find devices and rely on the device discovery callbacks or polling the connection cache
     * {@link FitbitGatt#getMatchingConnectionsForDeviceNames(List)} or {@link FitbitGatt#getMatchingConnectionsForServices(List)}
     *
     * @param context The android context for providing to the scanner
     * @return True if the scan started, false if it did not
     */

    public boolean startHighPriorityScan(Context context) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return false;
        }
        if (peripheralScanner == null) {
            Timber.w("You are trying to start a high-priority scan, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return false;
        }
        return peripheralScanner.startHighPriorityScan(context);
    }

    /**
     * Upon setting up your scan filters, this call will start to periodically scan for matching devices, it will notify via the {@link FitbitGatt.FitbitGattCallback}
     * interface if a device is discovered and will provide the {@link GattConnection} to you
     *
     * @param context The android context for the scanner
     * @return True if the scan started, false if it did not
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public boolean startPeriodicScan(Context context) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return false;
        }
        if (peripheralScanner == null) {
            Timber.w("You are trying to start a periodical scan, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return false;
        }
        return peripheralScanner.startPeriodicScan(context);
    }

    /**
     * Will cancel high priority and periodical scans that are currently running, but will not have any effect if using the
     * background PendingIntent based scanner, and will not un-schedule periodical scans. Use {@link PeripheralScanner#cancelPeriodicalScan(Context)}
     * to stop periodical scans.
     *
     * @param context The android context for the scanner
     */
    public void cancelScan(@Nullable Context context) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return;
        }
        if (peripheralScanner == null) {
            Timber.w("You are trying to cancel a scan, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.cancelScan(context);
    }

    /**
     * Will cancel a periodical scan, but will not have any effect if using the background PendingIntent
     * based scanner.  Will not cancel an in progress high priority scan
     *
     * @param context The android context for the scanner
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void cancelPeriodicalScan(@Nullable Context context) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return;
        }
        if (peripheralScanner == null) {
            Timber.w("You are trying to cancel a scan, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.cancelPeriodicalScan(context);
    }

    /**
     * Will cancel a high-priority scan, but will not have any effect if using the background PendingIntent
     * based scanner.  Will not cancel an enabled periodical scan
     *
     * @param context The android context for the scanner
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void cancelHighPriorityScan(@Nullable Context context) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return;
        }
        if (peripheralScanner == null) {
            Timber.w("You are trying to cancel a scan, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.cancelHighPriorityScan(context);
    }

    /**
     * Will put the scanner into mock mode which is useful for testing
     *
     * @param mockMode true to set mock mode, false to disable it.
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void setScannerMockMode(boolean mockMode) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to put the scanner into mock mode, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        alwaysConnectedScanner.setTestMode(mockMode);
    }

    /**
     * Will set filters on bluetooth device name, it is important to remember that these filters can
     * be rendered obsolete if the peripheral changes it's advertising device name.
     *
     * @param deviceNameFilters A list of device bluetooth names
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method// API Method
    public void setDeviceNameScanFilters(List<String> deviceNameFilters) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to set device name filters on the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.setDeviceNameFilters(deviceNameFilters);
    }

    /**
     * Will add a name onto the bluetooth device name filters, it is important to remember that these filters can
     * be rendered obsolete if the peripheral changes it's advertising device name.  Also, this function
     * will add a new name that will only take effect after the currently running scan.
     *
     * @param deviceName A device bluetooth names
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void addDeviceNameScanFilter(String deviceName) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to add a device name filter onto the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.addDeviceNameFilter(deviceName);
    }

    /**
     * Will set service uuid filters on the scanner, this works with adding proper service records
     * within the adv packet for the peripheral, but may not work with service records added as part
     * of the service data.  Please use the service data filter for these records to be certain as this
     * is dependent upon the HW implementation on the particular Android device.
     *
     * @param uuidFilters The service UUIDs upon which to filter advertising peripherals
     */

    public void setScanServiceUuidFilters(List<ParcelUuid> uuidFilters) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to set service uuid filters on the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.setServiceUuidFilters(uuidFilters);
    }

    /**
     * Will add the service UUID with a given mask to find multiple devices that conform to a uuid
     * service pattern in the advertisement.  Will only take effect after the current scan has ended
     * in the next scan.
     *
     * @param service The service parceluuid
     * @param mask    The parceluuid service mask
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void addScanServiceUUIDWithMaskFilter(ParcelUuid service, ParcelUuid mask) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to set service uuid with mask filters on the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.addServiceUUIDWithMask(service, mask);
    }

    /**
     * Add a filter for the scanner based on the service data.  Will only take effect after the current
     * scan has ended if one is running.
     *
     * @param serviceUUID     The parcel uuid for the service
     * @param serviceData     The actual service data
     * @param serviceDataMask The service data mask
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void addFilterUsingServiceData(ParcelUuid serviceUUID, byte[] serviceData, byte[] serviceDataMask) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to add a filter using service data to the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.addFilterUsingServiceData(serviceUUID, serviceData, serviceDataMask);
    }

    /**
     * Will filter scan results for a min rssi, not the most reliable way to determine nearby devices
     * since the RSSI values vary from phone to phone, but it is possible to build a model for this
     * and do this effectively.
     *
     * @param minRssi The minimum RSSI value to accept for a callback upon a found device
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void addScanRssiFilter(int minRssi) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to set rssi filters on the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.addRssiFilter(minRssi);
    }

    /**
     * Add scanner filter on device address. Will only take effect after the current scan has ended
     * if one is running.
     * <p>
     * Note: This filter is known to not be supported on some hardware like "HTC One" and some Huawei phones
     *
     * @param deviceAddress he device Bluetooth address for the filter. It needs to be in the
     *                      format of "01:02:03:AB:CD:EF". The device address can be validated using
     *                      {@link BluetoothAdapter#checkBluetoothAddress}.
     */
    @SuppressWarnings("WeakerAccess") // API Method
    public void addDeviceAddressFilter(String deviceAddress) {
        if (peripheralScanner == null) {
            Timber.w("You are trying to add a device address filter onto the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.addDeviceAddressFilter(deviceAddress);
    }

    /**
     * Will return a shallow copy of the current scan filters held by the scanner
     *
     * @return Shallow copy of the current scan filters
     */
    @SuppressWarnings("WeakerAccess") // API Method
    public List<ScanFilter> getScanFilters() {
        if (peripheralScanner == null) {
            Timber.w("You are trying to get scan filters, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return Collections.emptyList();
        }
        return peripheralScanner.getScanFilters();
    }

    /**
     * Will clear the scan filters currently applied, will not apply to the current scan if one is running.
     */
    @SuppressWarnings("WeakerAccess") // API Method
    public void resetScanFilters() {
        if (peripheralScanner == null) {
            Timber.w("You are trying to reset the scan filters on the scanner, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return;
        }
        peripheralScanner.resetFilters();
    }

    /**
     * To determine if the scanner is presently scanning or not
     *
     * @return true if a scan is currently happening, false if not
     */
    public boolean isScanning() {
        if (peripheralScanner == null) {
            Timber.w("You are trying to determine the scan state, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return false;
        }
        return peripheralScanner.isScanning();
    }

    /**
     * To determine if there is a pending intent scan going right now
     *
     * @return True if there is a pending intent scan occurring
     */
    @SuppressWarnings({"unused", "WeakerAccess"})
    // API Method
    public boolean isPendingIntentScanning() {
        if (peripheralScanner == null) {
            Timber.w("You are trying to determine the scan state, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return false;
        }
        return peripheralScanner.isPendingIntentScanning();
    }

    /**
     * To determine if there is a periodical scan enabled, should not be confused with whether a low priority scan is currently happening.
     *
     * @return True if there is a periodical scan enabled
     */
    @SuppressWarnings({"unused", "WeakerAccess"})
    // API Method
    public boolean isPeriodicalScanEnabled() {
        if (peripheralScanner == null) {
            Timber.w("You are trying to determine the scan state, but the scanner isn't set-up, did you call FitbitGatt#initializeScanner?");
            return false;
        }
        return peripheralScanner.isPeriodicalScanEnabled();
    }

    /**
     * Convenience method that attempts to start all the components
     *
     * @param context
     */
    @WorkerThread
    public synchronized void start(Context context) {
        startGattClient(context);
        startGattServer(context);
        initializeScanner(context);
    }

    /**
     * Initializes the scanner component
     */
    public synchronized boolean initializeScanner(@NonNull Context context) {
        boolean started = startSimple(context, (error -> {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onScannerInitError(error);
            }
        }));
        if (!isBluetoothOn()) {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onScannerInitError(new BluetoothNotEnabledException());
            }
            return false;
        }
        return started;
    }

    /**
     * Initializes the scanner with a a set of filters.
     * <p>
     * In this case it also starts a periodical scanner with it
     *
     * @param filters scan filters
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API method
    public synchronized void startPeriodicalScannerWithFilters(@NonNull Context context, List<ScanFilter> filters) {
        if (!initializeScanner(context)) {
            return;
        }
        if (isScanning()) {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onScannerInitError(new AlreadyStartedException());
            }
            // we cannot start what has been already started.
            return;
        }

        if (filters != null && !filters.isEmpty()) {
            peripheralScanner.setScanFilters(filters);
            alwaysConnectedScanner.setScanFilters(filters);
            peripheralScanner.startPeriodicScan(this.appContext);
        } else {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onScannerInitError(new NoFiltersSetException());
            }
        }
    }


    /**
     * Initialize bitgatt client dependencies.
     * Without calling this method we are not allowed to execute {@link GattTransaction} for client devices
     *
     * If bluetooth is on it adds as well the list of known devices to bitgatt. These include devices that
     * have are already connected or bonded.
     *
     * Does not automatically start scanning for other devices
     *
     */
    public synchronized void startGattClient(@NonNull Context context) {
        isGattClientStarted.set(true);
        if (!startSimple(context, (error -> {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattClientStartError(error);
            }
        }))) {
            return;
        }
        if (!isBluetoothOn()) {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattClientStartError(new BluetoothNotEnabledException());
            }
            return;
        }
        if (this.aclListener == null) {
            this.aclListener = dependencyProvider.getNewLowEnergyAclListener();
            this.aclListener.register(this.appContext);
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattClientStarted();
            }
            if (isBluetoothOn()) {
                addConnectedDevices(this.appContext);
            }
        }
    }

    synchronized void addConnectedDevice(BluetoothDevice device) {
        fitbitGattAsyncOperationHandler.post(() -> {
            FitbitBluetoothDevice fitbitBluetoothDevice = new FitbitBluetoothDevice(device);
            fitbitBluetoothDevice.origin = FitbitBluetoothDevice.DeviceOrigin.CONNECTED;
            addConnectedDeviceToConnectionMap(this.appContext, fitbitBluetoothDevice);
        });
    }

    @VisibleForTesting
    void addConnectedDeviceToConnectionMap(Context context, FitbitBluetoothDevice device) {
        Timber.v("Adding the new connected device");
        BluetoothAdapter adapter = dependencyProvider.getNewGattUtils().getBluetoothAdapter(context);
        if (adapter != null) {
            if (null == connectionMap.get(device)) {
                Timber.v("Adding connected device named %s, with address %s", device.getName(), device.getAddress());
                if (context != null) {
                    GattConnection conn = new GattConnection(device, context.getMainLooper());
                    connectionMap.put(device, conn);
                    FitbitGatt.getInstance().notifyListenersOfConnectionAdded(conn);
                } else {
                    Timber.w("Tried to add the connected device, but the cached context was null");
                }
            }
        }
        Timber.v("Added the new connected device");
    }

    /**
     * Starts the gatt server and allows the execution of {@link GattTransaction} on it
     *
     */
    public synchronized void startGattServer(@NonNull Context context) {
        startGattServerWithServices(context, null);
    }

    /**
     * Starts the gatt server and allows the execution of {@link GattTransaction} on it
     * Also adds the given service list on the gatt server. In the case that
     *
     * @param context Context
     * @param services The services desired
     */
    @WorkerThread
    @SuppressWarnings("WeakerAccess") // API Method
    public synchronized void startGattServerWithServices(@NonNull Context context, @Nullable List<BluetoothGattService> services) {
        isGattServerStarted.set(true);
        if (!startSimple(context, (error -> {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattServerStartError(error);
            }
        }))) {
            return;
        }

        if (!isBluetoothOn()) {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattServerStartError(new BluetoothNotEnabledException());
            }
            return;
        }

        if (serverConnection != null && gattServer != null && serverConnection.getGattState() != GattState.CLOSED) {
            //server already started and running
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattServerStartError(new AlreadyStartedException());
            }
            return;
        }

        if (isGattServerStarting.getAndSet(true)) {
            Timber.tag("FitbitGattServer").d("Server is already trying to start");
            return;
        }


        startServer(getOpenGattServerCallback(services));
    }

    @NonNull
    @VisibleForTesting
    OpenGattServerCallback getOpenGattServerCallback(@Nullable List<BluetoothGattService> services) {
        return started -> {

            if (!started) {
                Timber.w("Could not get an instance of a gatt server, if you keep trying without fixing the issue, you might end up with too many server_if");
                for (FitbitGattCallback readCallback : overallGattEventListeners) {
                    readCallback.onGattServerStartError(new MissingGattServerErrorException());
                }
                return;
            }
            if (services != null) {
                Timber.v("Starting to add services, will set to started after complete");
                // usually the android stack will add the service setup to the bt stack, if this stack
                // is busy, this can take a while, so we'll need to wait until we get the callbacks
                // for all of the expected services.
                if (!services.isEmpty()) {
                    servicesToAdd.clear();
                    servicesToAdd.addAll(services);
                    addServicesToGattServerOnStart();
                }
            }
        };
    }

    private synchronized void initialize(Context context) {
        if (!isInitialized.get()) {
            Timber.v("Starting fitbit gatt");
            appContext = context.getApplicationContext();
            peripheralScanner = dependencyProvider.getNewPeripheralScanner(this.appContext, this);
            connectionCleanup = new Handler(context.getMainLooper());

            Timber.v("Initializing the always connected scanner for one device, and that it should stop scanning when it finds one, if you wish to change this, please configure it.");
            if (radioStatusListener == null) {
                radioStatusListener = dependencyProvider.getNewBluetoothRadioStatusListener(this.appContext, false);
                radioStatusListener.startListening();
                radioStatusListener.setListener(this);
            }
            if (asyncOperationThreadWatchdog != null) {
                asyncOperationThreadWatchdog.startProbing();
            }
            clientCallback = new GattClientCallback();
            serverCallback = new GattServerCallback();
            isInitialized.set(true);
        }
    }

    private synchronized boolean startSimple(@NonNull Context context, StartErrorCallback errorHandler) {
        initialize(context);
        if (!isBluetoothOn()) {
            errorHandler.onError(new BluetoothNotEnabledException());
            return false;
        }
        // will start the cleanup process
        decrementAndInvalidateClosedConnections();
        return true;
    }


    /**
     * Will fetch the always connected scanner for configuration and starting.  The always connected
     * scanner is designed for you to delegate all of the scanning to bitgatt where you want to
     * always connect to a peripheral when in range, once started, no ad-hoc scanning can be
     * accomplished.
     *
     * @return the always connected scanner
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API method
    public @NonNull
    AlwaysConnectedScanner getAlwaysConnectedScanner() {
        return this.alwaysConnectedScanner;
    }

    /**
     * Do not do this unless you truly know what you are doing, there are very, very few reasons
     * to perform this operation.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    @SuppressWarnings({"unused", "WeakerAccess"})
    // API Method
    public void shutdown() {
        Timber.v("Someone wants to shutdown the gatt");
        this.overallGattEventListeners.clear();
        this.servicesToAdd.clear();
        this.connectionMap.clear();
        if (asyncOperationThreadWatchdog != null) {
            this.asyncOperationThreadWatchdog.stopProbing();
        }
        //clean up callbacks and listeners;
        if (serverCallback != null) {
            serverCallback.unregisterAll();
        }
        if (this.appContext != null && this.aclListener != null) {
            this.aclListener.unregister(this.appContext);
        }
        if (this.peripheralScanner != null) {
            this.peripheralScanner.onDestroy(this.appContext);
        }
        if (this.aclListener != null) {
            this.aclListener.unregister(this.appContext);
        }
        if (radioStatusListener != null) {
            radioStatusListener.stopListening();
            radioStatusListener.removeListener();
        }

        if(serverConnection != null) {
            List<ServerConnectionEventListener> serverConnectionEventListeners = serverConnection.getConnectionEventListeners();
            for (ServerConnectionEventListener serverConnectionEventListener : serverConnectionEventListeners) {
                serverConnection.unregisterConnectionEventListener(serverConnectionEventListener);
            }
            serverConnection.close();
            serverConnection = null;
            if(gattServer != null) {
                gattServer.close();
            }
        }
        //clear up all references
        this.gattServer = null;
        this.serverConnection = null;
        this.connectionCleanup = null;
        this.isInitialized.set(false);
        this.isGattClientStarted.set(false);
        this.isGattServerStarted.set(false);
        this.appContext = null;
        this.serverCallback = null;
        this.clientCallback = null;
        this.radioStatusListener = null;
        this.aclListener = null;
        this.peripheralScanner = null;
        this.asyncOperationThreadWatchdog = null;
    }

    @VisibleForTesting
    void setBluetoothListener(BluetoothRadioStatusListener listener) {
        this.radioStatusListener = listener;
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    synchronized boolean isInitialized() {
        return isInitialized.get();
    }

    public @Nullable
    Context getAppContext() {
        return appContext;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setStarted(boolean isStarted) {
        this.isInitialized.set(isStarted);
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setAppContext(Context context) {
        this.appContext = context;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setGattServerStarted(boolean isStarted) {
        this.isGattServerStarted.set(isStarted);
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setGattClientStarted(boolean isStarted) {
        this.isGattClientStarted.set(isStarted);
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setConnectionMap(ConcurrentHashMap<FitbitBluetoothDevice, GattConnection> map) {
        this.connectionMap.clear();
        this.connectionMap.putAll(map);
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setPeripheralScanner(PeripheralScanner scanner) {
        this.peripheralScanner = scanner;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setClientCallback(GattClientCallback callback) {
        this.clientCallback = callback;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setDependencyProvider(BitGattDependencyProvider provider) {
        this.dependencyProvider = provider;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setConnectionCleanup(Handler handler) {
        this.connectionCleanup = handler;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void setAsyncOperationThreadWatchdog(LooperWatchdog watchdog) {
        this.asyncOperationThreadWatchdog = watchdog;
    }

    @SuppressWarnings("WeakerAccess") // API Method
    @Nullable
    public GattServerCallback getServerCallback() {
        return this.serverCallback;
    }

    @SuppressWarnings("WeakerAccess") // API Method
    @Nullable
    public GattClientCallback getClientCallback() {
        return this.clientCallback;
    }

    @Nullable
    PeripheralScanner getPeripheralScanner() {
        if (this.peripheralScanner == null) {
            Timber.w("The scanner is null, did you call FitbitGatt#initializeScanner?");
        }
        return this.peripheralScanner;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public synchronized void putConnectionIntoDevices(FitbitBluetoothDevice device, GattConnection conn) {
        // we don't want duplicate connections in the map
        if (!connectionMap.containsKey(device)) {
            connectionMap.put(device, conn);
            // since this directly calls notify listeners of connection added
            // we will need to synchronize around those listeners since
            // someone could be adding or removing a listener while this asynchronous
            // call happens.  To do this easily, we will use a
            // CopyOnWriteArrayList for the {@link FitbitGattCallback}
            FitbitGatt.getInstance().notifyListenersOfConnectionAdded(conn);
        }
    }

    /**
     * Will check to determine if the provided {@link FitbitBluetoothDevice} is actually in the map
     *
     * @param device The {@link FitbitBluetoothDevice} for which to search
     * @return true if the device is present, false otherwise
     */

    @SuppressWarnings("WeakerAccess") // API Method
    public synchronized boolean isDeviceInConnections(FitbitBluetoothDevice device) {
        return connectionMap.containsKey(device);
    }

    private void notifyListenersOfConnectionAdded(GattConnection connection) {
        for (FitbitGattCallback callback : this.overallGattEventListeners) {
            callback.onBluetoothPeripheralDiscovered(connection);
        }
    }

    void notifyListenersOfConnectionDisconnected(GattConnection connection) {
        for (FitbitGattCallback callback : this.overallGattEventListeners) {
            callback.onBluetoothPeripheralDisconnected(connection);
        }
    }

    void registerGattServerListener(GattServerListener listener) {
        serverCallback.addListener(listener);

    }

    void unregisterGattServerListener(GattServerListener listener) {
        serverCallback.removeListener(listener);
    }

    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void connectToScannedDevice(BluetoothDevice device, GattTransactionCallback callback) {
        FitbitBluetoothDevice fitDevice = new FitbitBluetoothDevice(device);
        connectToScannedDevice(fitDevice, false, callback);
    }

    @VisibleForTesting
    void connectToScannedDevice(FitbitBluetoothDevice fitDevice, boolean shouldMock, GattTransactionCallback callback) {
        GattConnection conn = connectionMap.get(fitDevice);
        if (conn == null) {
            if (appContext == null) {
                Timber.w("[%s] Bitgatt client must not be started, please start bitgatt client", fitDevice);
                return;
            }
            conn = new GattConnection(fitDevice, appContext.getMainLooper());
            connectionMap.put(fitDevice, conn);
            notifyListenersOfConnectionAdded(conn);
        }
        conn.setMockMode(shouldMock);
        if (!conn.isConnected()) {
            GattConnectTransaction tx = new GattConnectTransaction(conn, GattState.CONNECTED);
            conn.runTx(tx, callback);
        } else {
            TransactionResult.Builder builder = new TransactionResult.Builder();
            builder.resultStatus(TransactionResult.TransactionResultStatus.SUCCESS)
                .gattState(conn.getGattState());
            callback.onTransactionComplete(builder.build());
        }
    }

    ConcurrentHashMap<FitbitBluetoothDevice, GattConnection> getConnectionMap() {
        return connectionMap;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    List<FitbitBluetoothDevice> getNewlyScannedDevicesOnly() {
        ArrayList<FitbitBluetoothDevice> devices = new ArrayList<>();
        for (FitbitBluetoothDevice iteratedDevice : getConnectionMap().keySet()) {
            if (iteratedDevice.origin.equals(FitbitBluetoothDevice.DeviceOrigin.SCANNED)) {
                devices.add(iteratedDevice);
            }
        }
        return devices;
    }

    @RestrictTo(RestrictTo.Scope.TESTS)
    void clearConnectionsMap() {
        connectionMap.clear();
    }

    /**
     * Will iterate through connections in the map to decrement those that need decrementing, and
     * will evict those that need to be evicted.  This should run for as long as the singleton
     * exists.
     */

    private void decrementAndInvalidateClosedConnections() {
        connectionCleanup.postDelayed(this::doDecrementAndInvalidateClosedConnections, CLEANUP_INTERVAL);
    }

    @VisibleForTesting
    void doDecrementAndInvalidateClosedConnections() {
        if (this.appContext == null) {
            Timber.w("[%s] Bitgatt must not be started, please start bitgatt client.", Build.DEVICE);
            return;
        }
        addConnectedDevices(this.appContext);
        for (FitbitBluetoothDevice fitbitBluetoothDevice : getConnectionMap().keySet()) {
            GattConnection conn = getConnectionMap().get(fitbitBluetoothDevice);
            if (conn != null) {
                // we only want to try to prune disconnected peripherals, there may be some
                // peripherals that are connected that we want to get rid of, but the caller
                // will need to disconnect them first, eventually we will clean up the
                // connection
                if (!conn.isConnected()) {
                    long currentTtl = conn.getDisconnectedTTL();
                    if (currentTtl <= 0) {
                        conn.close();
                        GattConnection connection = getConnectionMap().remove(fitbitBluetoothDevice);
                        if (connection != null) {
                            notifyListenersOfConnectionDisconnected(connection);
                            Timber.v("Connection for %s is disconnected and pruned", connection.getDevice());
                        }
                    } else {
                        conn.setDisconnectedTTL(currentTtl - CLEANUP_INTERVAL);
                    }
                }
            }
        }
        decrementAndInvalidateClosedConnections();
    }

    @VisibleForTesting
    synchronized void addScannedDevice(FitbitBluetoothDevice device) {
        // we need to deal with the scenario where the peripheral was connected, but now
        // it is disconnected, then it is picked up in the background with the scan
        // the listener could potentially be called back twice for the same connection
        // if the user has a background scan running while an active scan is running
        GattConnection conn = connectionMap.get(device);
        if (null == conn) {
            if (appContext == null) {
                Timber.w("[%s] Bitgatt must not be started, please start bitgatt client", device);
                return;
            }
            Timber.v("Adding scanned device %s", device.toString());
            conn = new GattConnection(device, appContext.getMainLooper());
            device.origin = FitbitBluetoothDevice.DeviceOrigin.SCANNED;
            connectionMap.put(device, conn);
            notifyListenersOfConnectionAdded(conn);
        } else {
            FitbitBluetoothDevice oldDevice = conn.getDevice();
            String previousDeviceName = oldDevice.getName();
            ScanRecord previousScanRecord = oldDevice.getScanRecord();
            int previousRssi = oldDevice.getRssi();
            if (!previousDeviceName.equals(device.getName())) {
                Timber.w("This device has the same mac (bluetooth ID) as a known device, but has changed it's BT name, IRL be careful this can break upstream logic, or have security implications.");
            }
            oldDevice.origin = FitbitBluetoothDevice.DeviceOrigin.SCANNED;
            if (!previousDeviceName.equals(device.getName()) ||
                previousRssi != device.getRssi() ||
                (previousScanRecord != null && device.getScanRecord() != null &&
                    !Arrays.equals(previousScanRecord.getBytes(), device.getScanRecord().getBytes()))) {
                //Timber.v("Found device may have changed was %s, and now is %s", oldDevice, device);
                oldDevice.setName(device.getName());
                oldDevice.setScanRecord(device.getScanRecord());
                oldDevice.setRssi(device.getRssi());
            }
            notifyListenersOfConnectionAdded(conn);
        }
    }

    /**
     * Will create connection objects representing all of the BTLE devices connected presently
     * or bonded to this phone
     */
    private void addConnectedDevices(Context context) {
        fitbitGattAsyncOperationHandler.post(() -> {
            Timber.v("Adding connected or bonded devices");
            BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
            if (manager != null) {
                BluetoothAdapter adapter = manager.getAdapter();
                if (adapter != null) {
                    Set<BluetoothDevice> bondedDevices = adapter.getBondedDevices();
                    for (BluetoothDevice bondedDevice : bondedDevices) {
                        FitbitBluetoothDevice fitBluetoothDevice = new FitbitBluetoothDevice(bondedDevice);
                        fitBluetoothDevice.origin = FitbitBluetoothDevice.DeviceOrigin.BONDED;
                        if (null == connectionMap.get(fitBluetoothDevice)) {
                            if (appContext == null) {
                                Timber.w("[%s] Bitgatt must not be started, please start bitgatt", fitBluetoothDevice);
                                return;
                            }
                            GattConnection conn = new GattConnection(fitBluetoothDevice, appContext.getMainLooper());
                            Timber.v("Adding bonded device named %s, with address %s", bondedDevice.getName(), bondedDevice.getAddress());
                            connectionMap.put(fitBluetoothDevice, conn);
                            FitbitGatt.getInstance().notifyListenersOfConnectionAdded(conn);
                        }
                    }
                    List<BluetoothDevice> connectedDevices = manager.getConnectedDevices(BluetoothProfile.GATT);
                    for (BluetoothDevice connectedDevice : connectedDevices) {
                        FitbitBluetoothDevice fitbitBluetoothDevice = new FitbitBluetoothDevice(connectedDevice);
                        fitbitBluetoothDevice.origin = FitbitBluetoothDevice.DeviceOrigin.CONNECTED;
                        if (null == connectionMap.get(fitbitBluetoothDevice)) {
                            Timber.v("Adding connected device named %s, with address %s", connectedDevice.getName(), connectedDevice.getAddress());
                            if (appContext != null) {
                                GattConnection conn = new GattConnection(fitbitBluetoothDevice, appContext.getMainLooper());
                                connectionMap.put(fitbitBluetoothDevice, conn);
                                FitbitGatt.getInstance().notifyListenersOfConnectionAdded(conn);
                            } else {
                                Timber.w("Tried to add a discovered device, but the cached context was null");
                            }
                        }
                    }
                }
            }
            Timber.v("Added all connected or bonded devices");
        });
    }

    /**
     * Will return a list of {@link GattConnection} objects that match the provided bluetooth device
     * names, providing a null list returns all connections
     *
     * @param names The list of bluetooth device names by which to filter the list
     * @return The list of connections matching these names
     */
    public List<GattConnection> getMatchingConnectionsForDeviceNames(@Nullable List<String> names) {
        ArrayList<GattConnection> connections = new ArrayList<>(2);
        if (names == null) {
            connections.addAll(connectionMap.values());
            return connections;
        }
        for (String name : names) {
            Enumeration<FitbitBluetoothDevice> fitbitDeviceEnumeration = connectionMap.keys();
            while (fitbitDeviceEnumeration.hasMoreElements()) {
                FitbitBluetoothDevice device = fitbitDeviceEnumeration.nextElement();
                if (device.getName().equals(name)) {
                    connections.add(connectionMap.get(device));
                }
            }
        }
        return connections;
    }

    /**
     * Will return a list of connections that match a series of service UUIDs, will return if any
     * of the services provided matches a hosted service.  If discovery has not been performed
     * on the connected device, or if it is not connected, it will not be returned.
     *
     * @param services A list of services to filter
     * @return The connections that match any of the items in the list
     */
    @SuppressWarnings("WeakerAccess") // API Method
    public List<GattConnection> getMatchingConnectionsForServices(@Nullable List<UUID> services) {
        ArrayList<GattConnection> connections = new ArrayList<>(2);
        if (services == null) {
            connections.addAll(connectionMap.values());
            return connections;
        }
        Enumeration<FitbitBluetoothDevice> fitbitDeviceEnumeration = connectionMap.keys();
        while (fitbitDeviceEnumeration.hasMoreElements()) {
            FitbitBluetoothDevice device = fitbitDeviceEnumeration.nextElement();
            GattConnection conn = connectionMap.get(device);
            if (conn != null) {
                BluetoothGatt gatt = conn.getGatt();
                if ((conn.getMockMode() || gatt != null) && conn.isConnected()) {
                    for (UUID serviceUuid : services) {
                        if (conn.connectedDeviceHostsService(serviceUuid)) {
                            connections.add(conn);
                        }
                    }
                }
            }
        }
        return connections;
    }

    /**
     * Will retrieve a connection from the map if one exists using the bluetooth mac address
     * of the device, or will return null if it does not.
     *
     * @param bluetoothAddress The bluetooth mac address
     * @return NULL if error creating creating connection, the connection if it does
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public @Nullable
    GattConnection getConnectionForBluetoothAddress(String bluetoothAddress) {
        if (appContext != null) {
            return getConnectionForAddress(bluetoothAddress);
        } else {
            Timber.w("Error getting connection FitbitGatt state %s", isInitialized());
            return null;
        }
    }

    /**
     * Will retrieve a connection from the map if one exists using the bluetooth mac address
     * of the device, or will create one if it does not.
     *
     * @param context          The Android context
     * @param bluetoothAddress The bluetooth mac address
     * @return NULL if error creating creating connection, the connection if it does
     * @deprecated Using this method is discouraged as it will soon become private
     */
    @Deprecated
    public @Nullable
    GattConnection getConnectionForBluetoothAddress(Context context, String bluetoothAddress) {
        BluetoothManager mgr = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
        if (mgr != null) {
            BluetoothAdapter adp = mgr.getAdapter();
            if (adp != null) {
                BluetoothDevice bluetoothDevice = adp.getRemoteDevice(bluetoothAddress);
                return getConnection(bluetoothDevice);
            } else {
                Timber.e("Couldn't fetch the connection because we couldn't initialize the adapter");
                return null;
            }
        } else {
            Timber.e("Couldn't fetch the connection because we couldn't initialize the manager");
            return null;
        }
    }

    /**
     * Synchronized because this can be called in effect from bluetooth on and startGattServer, there could
     * be some odd behavior if this were called concurrently
     *
     * @param callback The async callback for resolving the gatt server open
     */
    private synchronized void startServer(OpenGattServerCallback callback) {
        BluetoothManager manager = dependencyProvider.getNewGattUtils().getBluetoothManager(this.appContext);
        if (manager != null && manager.getAdapter() != null) {
            /*
             * We've observed that the registration of the callback inside of the android
             * source for the gatt_if can lead to a hang for up to 13 seconds, but the
             * ANR time is 5 seconds currently (Summer 2019)
             *
             * If this occurs, then you probably should tell the user to clear their bluetooth
             * application's data in the application list with system apps shown.  The gatt
             * cache is probably corrupt
             */
            fitbitGattAsyncOperationHandler.post(tryAndStartGattServer(this.appContext, callback, manager));
        } else {
            Timber.w("No bluetooth manager, we must be simulating, or BT is off!!!");
            callback.onGattServerStatus(false);
        }
    }

    @NonNull
    @VisibleForTesting
    Runnable tryAndStartGattServer(Context context, OpenGattServerCallback callback, BluetoothManager manager) {
        return () -> {
            synchronized (FitbitGatt.this) {
                //may have been started already in another thread in parallel trough another post
                //We observed this behaviour inside the FitbitGattTest instrumentation test
                //when trying to start the gatt server multiple times
                Timber.tag("FitbitGattServer").d("Trying to start the gatt server");
                if (gattServer != null) {
                    gattServer.close();
                }
                for (int openServerRetryCount = 0; openServerRetryCount < OPEN_GATT_SERVER_RETRY_COUNT; openServerRetryCount++) {
                    gattServer = manager.openGattServer(context, serverCallback);
                    if (gattServer != null) {
                        if (serverConnection != null) {
                            // let's try to run a clear tx since we already have a server connection
                            ClearServerServicesTransaction clearServices = new ClearServerServicesTransaction(serverConnection,
                                    GattState.CLEAR_GATT_SERVER_SERVICES_SUCCESS);
                            serverConnection.runTx(clearServices, getGattClearServicesTransactionCallback(context, callback));
                            return;
                        } else {
                            gattServer.clearServices();
                            setGattServerConnection(new GattServerConnection(gattServer, context.getMainLooper()));
                            serverConnection.setState(GattState.IDLE);
                            callback.onGattServerStatus(true);
                            isGattServerStarting.set(false);
                            Timber.tag("FitbitGattServer").v("Gatt server successfully opened");
                            return;
                        }
                    }
                }
                isGattServerStarting.set(false);
                Timber.tag("FitbitGattServer").w("Exhausted retries to open gatt server, recommend that you tell your user to clear bluetooth share in the apps list, the GATT db is probably corrupt");
                callback.onGattServerStatus(false);
            }
        };
    }

    @NonNull
    private GattTransactionCallback getGattClearServicesTransactionCallback(Context context, OpenGattServerCallback callback) {
        return result -> {
            // whether this succeeds or not, we want to proceed
            Timber.v("The gatt server services were cleared %s", result.resultStatus);
            setGattServerConnection(new GattServerConnection(gattServer, context.getMainLooper()));
            serverConnection.setState(GattState.IDLE);
            callback.onGattServerStatus(true);
            isGattServerStarting.set(false);
        };
    }

    /**
     * Will add a device that was discovered via the background scan in a provided scan result to
     * the connected devices map and will notify listeners of the availability of the new connection.  This will allow
     * an API user to add devices from scans that occur outside of the context of the periodical scanner.
     *
     * @param device The scan result from the background system scan
     */

    synchronized void addBackgroundScannedDeviceConnection(@Nullable FitbitBluetoothDevice device) {
        if (device != null) {
            device.origin = FitbitBluetoothDevice.DeviceOrigin.SCANNED;
            addScannedDevice(device);
        } else {
            Timber.w("No result provided.");
        }
    }

    /**
     * Will add a device that was discovered via the background scan in a provided scan result to
     * the connected devices map and will notify listeners of the availability of the new connection.  This will allow
     * an API user to add devices from scans that occur outside of the context of the periodical scanner.
     *
     * @param result The scan result from the background system scan
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public synchronized void addBackgroundScannedDeviceConnection(@Nullable ScanResult result) {
        if (result != null) {
            BluetoothDevice bluetoothDevice = result.getDevice();
            FitbitBluetoothDevice device = new FitbitBluetoothDevice(bluetoothDevice);
            device.origin = FitbitBluetoothDevice.DeviceOrigin.SCANNED;
            device.setRssi(result.getRssi());
            addScannedDevice(device);
        } else {
            Timber.w("No result provided.");
        }
    }

    /**
     * To provide an API to attempt to always find a bluetooth device that the caller wants to know
     * is in close proximity.  If this is attempted on a version prior to Android Oreo,
     * the result will be a no-op and will return false.  This API can be used to remain connected
     * to a particular set of devices.  The type of scan that occurs via this API is a privileged
     * scan that can run generally forever, but is not entirely under our control.
     * This scan is run by the system, so the resulting broadcast intent's content RE the bluetooth
     * device may change with the Android version.  Be advised that this scan will be automatically
     * cancelled if the user turns bluetooth off.
     *
     * @param context         The Android context for creating the pending intent
     * @param broadcastIntent The broadcast intent to be sent when the device is found.
     *                        Will wake up application if process is dead.
     * @param macAddresses    The specific mac addresses for which to be called back
     * @return The pending intent that should be used to cancel the scan if desired, or null
     * if the scan wasn't started.
     */
    @SuppressWarnings({"WeakerAccess", "unused"}) // API Method
    public PendingIntent startBackgroundScan(@NonNull Context context, @NonNull Intent broadcastIntent, @NonNull List<String> macAddresses) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return null;
        }
        if (isInitialized() && peripheralScanner != null) {
            return peripheralScanner.startBackgroundScan(macAddresses, broadcastIntent, context);
        } else {
            Timber.w("The FitbitGatt must have been started in order to use the background scanner.");
            return null;
        }
    }

    /**
     * Will start a background scan that will continue to run even if our process is killed.  This
     * will internally handle the result of particular intent based scan results and deliver
     * connection callbacks when items are found.  In order to start a pending intent based scan you will
     * need to stop any existing high-priority scan in order to enable the pending intent based scan, the intended
     * use of this API is for scanning while your application is in the background.  When you
     * come into the foreground, you should cancel the background scan
     * with {@link FitbitGatt#stopSystemManagedPendingIntentScan()} unless you want for
     * the background scan to continue. Be advised that this might result in multiple callbacks to
     * {@link FitbitGatt.FitbitGattCallback#onBluetoothPeripheralDiscovered(GattConnection)}.
     * <p>
     * This background scan will be auto cancelled by the Android operating system in a way that we
     * can not control if BT is turned off or if the phone is rebooted.  This is a function of the
     * pending intent scan Android API.
     * <p>
     * WARNING!!!! Using this with scan filters that are empty is extremely dangerous and is frowned upon
     * your application will potentially get hundreds of intent callbacks every second.  Please do
     * not use this to get around the scanfilter empty check.
     *
     * @param context     The Android context for creating the pending intent
     * @param scanFilters The specific scan filters for which to be called back
     * @return true if the scan was able to be started, false if not
     */
    public boolean startSystemManagedPendingIntentScan(@NonNull Context context, @NonNull List<ScanFilter> scanFilters) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return false;
        }
        if (isInitialized() && peripheralScanner != null) {
            return peripheralScanner.startPendingIntentBasedBackgroundScan(scanFilters, context);
        } else {
            Timber.i("Can't start because scanner has not been initialized");
            return false;
        }
    }

    /**
     * Will stop the currently running pending intent based scan
     */
    public void stopSystemManagedPendingIntentScan() {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return;
        }
        if (isInitialized() && peripheralScanner != null) {
            try {
                peripheralScanner.cancelPendingIntentBasedBackgroundScan();
            } catch (NoSuchMethodError error) {
                Timber.i(error, "There was a no such method error stopping the pending intent scan, assuming stopped.");
            }
        } else {
            Timber.i("Can't stop because we aren't started, or the scanner is null");
        }
    }

    /**
     * Will stop the background scan started earlier by {@link FitbitGatt#startBackgroundScan(Context, Intent, List)}
     *
     * @param pendingIntent The pending intent returned by {@link FitbitGatt#startBackgroundScan(Context, Intent, List)}
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void stopBackgroundScan(@Nullable PendingIntent pendingIntent) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return;
        }
        if (pendingIntent == null) {
            Timber.v("No pending intent.");
        } else {
            if (peripheralScanner != null) {
                peripheralScanner.stopBackgroundScan(pendingIntent);
            } else {
                Timber.w("Peripheral scanner was null, did you forget to call FitbitGatt#initializeScanner?");
            }
        }
    }

    /**
     * Will stop the background scan with a regular intent
     *
     * @param context       The android context
     * @param regularIntent The regular intent
     */
    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public void stopBackgroundScanWithRegularIntent(Context context, @Nullable Intent regularIntent) {
        if (alwaysConnectedScanner.isAlwaysConnectedScannerEnabled()) {
            Timber.i("You are using the always connected scanner, stop it first before ad-hoc scanning");
            return;
        }
        if (regularIntent == null) {
            Timber.v("No intent.");
        } else {
            PendingIntent pending;
            try {
                pending = dependencyProvider.getNewScanPendingIntent(context, regularIntent);
            } catch (NoSuchMethodError error) {
                Timber.i(error, "There was a no such method error stopping the pending intent scan, assuming stopped");
                return;
            }
            if (peripheralScanner != null && pending != null) {
                peripheralScanner.stopBackgroundScan(pending);
            } else {
                Timber.w("Peripheral scanner was null, did you forget to call FitbitGatt#initializeScanner?");
            }
        }
    }

    public GattServerConnection getServer() {
        return serverConnection;
    }


    public @Nullable
    GattConnection getConnection(@Nullable BluetoothDevice device) {
        if (device == null || device.getAddress() == null) {
            return null;
        }
        return getConnectionForAddress(device.getAddress());
    }

    /**
     * Will fetch a connection object if one is present for a given bluetooth address
     *
     * @param bluetoothAddress The bluetooth MAC address
     * @return The GattConnection object
     */

    @SuppressWarnings({"unused", "WeakerAccess"}) // API Method
    public @Nullable
    GattConnection getConnectionForAddress(@Nullable String bluetoothAddress) {
        if (bluetoothAddress == null) {
            return null;
        }
        for (FitbitBluetoothDevice device : connectionMap.keySet()) {
            if (device.getAddress().equals(bluetoothAddress)) {
                return connectionMap.get(device);
            }
        }
        return null;
    }

    /**
     * This method will create a new connection if one does not already exist for the provided
     * device and add it to the connection map
     *
     * @param device The fitbit bluetooth device
     * @return The connection, it can be null if no connection is in the map, this is necessary to prevent too many client_ifs from races
     */
    public @Nullable
    GattConnection getConnection(FitbitBluetoothDevice device) {
        return connectionMap.get(device);
    }

    @TargetApi(24)
    @SuppressWarnings("WeakerAccess") // API Method
    protected BluetoothAdapter getAdapter(Context context) {
        if (atLeastSDK(Build.VERSION_CODES.M)) {
            BluetoothManager manager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
            assert manager != null;
            return manager.getAdapter();
        } else {
            return BluetoothAdapter.getDefaultAdapter();
        }
    }

    /**
     * Returns the BluetoothDevice Object for a remote Device that has the mac Address specified
     * <p> The mac Address should be a valid, such as "00:43:A8:23:10:F0"
     * Alphabetic characters must be uppercase to be valid.
     *
     * @param macAddress the mac address of the bluetooth device in question
     * @return The bluetooth device or null if not connected
     */
    public BluetoothDevice getBluetoothDevice(String macAddress) {
        BluetoothAdapter adapter = getAdapter(appContext);
        if (adapter != null && BluetoothAdapter.checkBluetoothAddress(macAddress)) {
            return adapter.getRemoteDevice(macAddress);
        }
        return null;
    }

    /**
     * Simple function to return true if the current device is running the given API Level or higher.
     * Recommended to use as a static import when comparing api levels
     *
     * @param buildVersion the buildVersion or API Level as defined by android.os.Build.VERSION_CODES.
     * @return true if the current device's API level is equal to or greater than the given API Level code
     */
    public static boolean atLeastSDK(int buildVersion) {
        return Build.VERSION.SDK_INT >= buildVersion;
    }


    @Override
    public void onScanStatusChanged(boolean isScanning) {
        Timber.i("On scan status changed %b", isScanning);
        if (isScanning) {
            for (FitbitGattCallback callback : this.overallGattEventListeners) {
                callback.onScanStarted();
            }
        } else {
            for (FitbitGattCallback callback : this.overallGattEventListeners) {
                callback.onScanStopped();
            }
        }
    }

    @Override
    public void onPendingIntentScanStatusChanged(boolean isScanning) {
        if (isScanning) {
            for (FitbitGattCallback callback : this.overallGattEventListeners) {
                callback.onPendingIntentScanStarted();
            }
        } else {
            for (FitbitGattCallback callback : this.overallGattEventListeners) {
                callback.onPendingIntentScanStopped();
            }
        }
    }

    @Override
    public void onFitbitDeviceFound(FitbitBluetoothDevice device) {
        addScannedDevice(device);
    }

    private void addServicesToGattServerOnStart() {
        GattServerConnection server = getServer();
        if (server != null) {
            CompositeServerTransaction addServicesTransaction = new CompositeServerTransaction(server, getGattAddServerServiceTransactions(servicesToAdd));
            server.runTx(addServicesTransaction, getGattAddServicesOnTransactionCallback());
        } else {
            for (FitbitGattCallback cb : overallGattEventListeners) {
                cb.onGattServerStartError(new MissingGattServerErrorException());
            }
        }

    }

    @NonNull
    private List<GattServerTransaction> getGattAddServerServiceTransactions(List<BluetoothGattService> services) {
        List<GattServerTransaction> transactions = new ArrayList<>();
        for (BluetoothGattService service : services) {
            transactions.add(new AddGattServerServiceTransaction(getServer(), GattState.ADD_SERVICE_SUCCESS, service));
        }
        return transactions;
    }

    @NonNull
    private GattTransactionCallback getGattAddServicesOnTransactionCallback() {
        return result -> {
            Timber.d("Gatt server init add service result: %s", result);
            processAddServiceOnStartResult(result);
        };
    }

    private void processAddServiceOnStartResult(TransactionResult result) {
        if (!result.resultStatus.equals(TransactionResult.TransactionResultStatus.SUCCESS)) {
            List<TransactionResult> results = result.getTransactionResults();
            for (TransactionResult tr : results) {
                if (!tr.resultStatus.equals(TransactionResult.TransactionResultStatus.SUCCESS)) {
                    for (FitbitGattCallback cb : overallGattEventListeners) {
                        cb.onGattServerStartError(new AddingServiceOnStartException(tr.getServiceUuid()));
                    }
                }
            }
        }
    }

    /**
     * Clean up the states of the devices, the bluetooth adapter is turning off so we will have to
     * mark them all as disconnected and start the TTL
     */
    private void cleanUpBecauseBluetoothIsTurningOff() {
        for (Map.Entry<FitbitBluetoothDevice, GattConnection> entry : getConnectionMap().entrySet()) {
            cleanUpConnection(entry.getValue());
        }
        // need to clean up scanner also, if BT turns off then we can no longer be scanning
        if (getPeripheralScanner() != null) {
            if (getPeripheralScanner().isPendingIntentScanning() || getPeripheralScanner().isScanning()) {
                // we will try to clean up the normal way
                getPeripheralScanner().cancelPendingIntentBasedBackgroundScan();
                getPeripheralScanner().cancelScan(appContext);
            }
        }
    }

    private void cleanUpConnection(GattConnection conn) {
        // by setting the state to bt_off no additional transactions can run except for
        // connect.  The existing transaction might timeout in this case, but this seems to be
        // the best way.
        conn.cleanUpConnection();
        conn.justClearGatt();
        conn.setState(GattState.BT_OFF);

    }

    private void switchAllConnectionsToDisconnectedBecauseBtIsOn() {
        for (Map.Entry<FitbitBluetoothDevice, GattConnection> entry : getConnectionMap().entrySet()) {
            entry.getValue().setState(GattState.DISCONNECTED);
        }
    }

    @Override
    public void bluetoothOff() {
        isBluetoothOn = false;
        Timber.v("Bluetooth is off");
        cleanUpBecauseBluetoothIsTurningOff();
        for (FitbitGattCallback callback : overallGattEventListeners) {
            callback.onBluetoothOff();
        }
    }

    @Override
    public void bluetoothOn() {
        isBluetoothOn = true;
        /*
         * In testing we see that sometimes there will be a dead object exception from the stack
         * after bluetooth is turned off and then on again.  It seems that the IBluetoothGatt is
         * no longer connected to any process, to make sure that we don't have this situation, let's
         * replace the instance.
         *
         */
        if (!isInitialized() || this.appContext == null) {
            Timber.e("Refreshing the gatt server after BT was enabled failed. Bitgatt has not been started");
            return;
        }
        if (isGattServerStarted.get()) {
            startServer(getOpenGattServerCallbackOnBluetoothOn());
        }
        if (isGattClientStarted.get()) {
            switchAllConnectionsToDisconnectedBecauseBtIsOn();
        }
        for (FitbitGattCallback callback : overallGattEventListeners) {
            callback.onBluetoothOn();
        }
    }

    @NonNull
    @VisibleForTesting
    OpenGattServerCallback getOpenGattServerCallbackOnBluetoothOn() {
        return started -> {
            if (started) {
                Timber.v("Gatt server up and ready");
            } else {
                Timber.w("After several attempts the gatt server could not be re-opened, tread lightly");
            }
            if (servicesToAdd != null && !servicesToAdd.isEmpty()) {
                addServicesToGattServerOnStart();
            }
            Timber.v("Bluetooth is on");
        };
    }

    @Override
    public void bluetoothTurningOff() {
        isBluetoothOn = false;
        // let's try to clean up the gatt server on devices that are likely to duplicate or host
        // no services after add on startup due to queueing issues, almost all Samsung devices
        // seem to behave in this way
        AndroidDevice strategyDevice = new AndroidDevice.Builder().manufacturerName("Samsung").build();
        Strategy executableStrategy = new StrategyProvider()
            .getStrategyForPhoneAndGattConnection(strategyDevice,
                null,
                Situation.CLEAR_GATT_SERVER_SERVICES_DEVICE_FUNKY_BT_IMPL);
        if(executableStrategy != null) {
            // we don't want to run any other strategies that may end up
            // with this situation
            if(executableStrategy instanceof BluetoothOffClearGattServerStrategy) {
                executableStrategy.applyStrategy();
            }
        }
        Timber.v("Bluetooth is turning off");
        for (FitbitGattCallback callback : overallGattEventListeners) {
            callback.onBluetoothTurningOff();
        }
        // you can not cancel the scan here because if you do, there is a chance that the actual
        // scanner implementation inside of the adapter could become null, even if you cache
        // the reference, like in IPD-103133 where we set it and in the next call it's null
        cleanUpBecauseBluetoothIsTurningOff();
    }

    @Override
    public void bluetoothTurningOn() {
        // still can't use it until it's on
        isBluetoothOn = false;
        Timber.v("Bluetooth is turning on");
        for (FitbitGattCallback callback : overallGattEventListeners) {
            callback.onBluetoothTurningOn();
        }
    }

    /**
     * @return true if log statements that may slow down data transfer speeds should be executed
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public boolean isSlowLoggingEnabled() {
        return slowLoggingEnabled;
    }

    /**
     * Use this method to enable or disable (default) log statements that may slow down data transfer speeds
     * @param newValue true to enable these logs, false to disable.
     */
    @SuppressWarnings({"unused"}) // API Method
    public void setSlowLoggingEnabled(boolean newValue) {
        slowLoggingEnabled = newValue;
    }
}