package com.ficat.easyble;

import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.text.TextUtils;

import com.ficat.easyble.gatt.BleGatt;
import com.ficat.easyble.gatt.BleGattImpl;
import com.ficat.easyble.gatt.bean.CharacteristicInfo;
import com.ficat.easyble.gatt.bean.ServiceInfo;
import com.ficat.easyble.gatt.callback.BleConnectCallback;
import com.ficat.easyble.gatt.callback.BleMtuCallback;
import com.ficat.easyble.gatt.callback.BleNotifyCallback;
import com.ficat.easyble.gatt.callback.BleReadCallback;
import com.ficat.easyble.gatt.callback.BleRssiCallback;
import com.ficat.easyble.gatt.callback.BleWriteByBatchCallback;
import com.ficat.easyble.gatt.callback.BleWriteCallback;
import com.ficat.easyble.scan.BleScan;
import com.ficat.easyble.scan.BleScanCallback;
import com.ficat.easyble.scan.BleScanner;

import java.lang.reflect.Constructor;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public final class BleManager {
    private Context mContext;
    private BluetoothAdapter mBluetoothAdapter;
    private ScanOptions mScanOptions;
    private ConnectOptions mConnectOptions;
    private BleScan<BleScanCallback> mScan;
    private BleGatt mGatt;

    private static volatile BleManager instance;


    private BleManager() {

    }

    public BleManager init(Context context) {
        if (mContext != null) {
            Logger.d("you have called init() already");
            return this;
        }
        if (context == null) {
            throw new IllegalArgumentException("context is null");
        }
        if (context instanceof Activity) {
            Logger.w("Activity Leak Risk:" + context.getClass().getSimpleName());
        }
        mContext = context;
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        mScan = new BleScanner();
        mGatt = new BleGattImpl(mContext);
        registerBleReceiver();
        return this;
    }

    private void registerBleReceiver() {
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
        mContext.registerReceiver(BleReceiver.getInstance(), intentFilter);
    }

    public static BleManager getInstance() {
        if (instance == null) {
            synchronized (BleManager.class) {
                if (instance == null) {
                    instance = new BleManager();
                }
            }
        }
        return instance;
    }

    public BleManager setScanOptions(ScanOptions options) {
        mScanOptions = options;
        return this;
    }

    public BleManager setConnectionOptions(ConnectOptions options) {
        mConnectOptions = options;
        return this;
    }

    public BleManager setLog(boolean enable, String tag) {
        Logger.SHOW_LOG = enable;
        if (!TextUtils.isEmpty(tag)) {
            Logger.TAG = tag;
        }
        return this;
    }

    public boolean isScanning() {
        return mScan.isScanning();
    }

    /**
     * Scan ble device
     */
    public void startScan(BleScanCallback callback) {
        startScan(mScanOptions, callback);
    }

    public void startScan(ScanOptions options, BleScanCallback callback) {
        if (options == null) {
            options = ScanOptions.newInstance();
        }
        mScan.startScan(options.scanPeriod, options.scanDeviceName, options.scanDeviceAddress,
                options.scanServiceUuids, callback);
    }

    /**
     * Stop scanning device, it's strongly recommended that you call this method
     * to stop scanning after target device has been discovered
     */
    public void stopScan() {
        mScan.stopScan();
    }

    /**
     * Connect to the remote device
     */
    public void connect(BleDevice device, BleConnectCallback callback) {
        connect(device, mConnectOptions, callback);
    }

    public void connect(BleDevice device, ConnectOptions options, BleConnectCallback callback) {
        if (options == null) {
            options = ConnectOptions.newInstance();
        }
        mGatt.connect(options.connectTimeout, device, callback);
    }

    /**
     * Connect to remote device by address
     */
    public void connect(String address, BleConnectCallback callback) {
        connect(address, mConnectOptions, callback);
    }

    public void connect(String address, ConnectOptions options, BleConnectCallback callback) {
        checkBluetoothAddress(address);
        BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(address);
        BleDevice bleDevice = newBleDevice(device);
        if (bleDevice == null) {
            Logger.d("new BleDevice fail!");
            return;
        }
        connect(bleDevice, options, callback);
    }

    /**
     * Disconnect from the remote device
     *
     * @param device remote device
     */
    public void disconnect(BleDevice device) {
        if (device == null) {
            throw new IllegalArgumentException("BleDevice is null");
        }
        disconnect(device.address);
    }

    /**
     * Disconnect from the remote device
     *
     * @param address remote device address
     */
    public void disconnect(String address) {
        checkBluetoothAddress(address);
        mGatt.disconnect(address);
    }

    /**
     * Disconnect all connected devices
     */
    public void disconnectAll() {
        mGatt.disconnectAll();
    }

    /**
     * Listen remote device notification/indication by specific notification/indication
     * characteristic
     *
     * @param device      remote device
     * @param serviceUuid service uuid which the notification or indication uuid belongs to
     * @param notifyUuid  characteristic uuid that you wanna notify or indicate, note that
     *                    the characteristic must support notification or indication, or it
     *                    will call back onFail()
     * @param callback    notification callback
     */
    public void notify(BleDevice device, String serviceUuid, String notifyUuid, BleNotifyCallback callback) {
        mGatt.notify(device, serviceUuid, notifyUuid, callback);
    }

    /**
     * Cancel notification/indication
     *
     * @param device             remote device
     * @param serviceUuid        service uuid
     * @param characteristicUuid characteristic uuid you want to stop notifying or indicating
     */
    public void cancelNotify(BleDevice device, String serviceUuid, String characteristicUuid) {
        mGatt.cancelNotify(device, serviceUuid, characteristicUuid);
    }

    /**
     * Write data to the remote device by specific writable characteristic
     *
     * @param device      remote device
     * @param serviceUuid service uuid that the writable characteristic belongs to
     * @param writeUuid   characteristic uuid which you write data, note that the
     *                    characteristic must be writable, or it will call back onFail()
     * @param data        data
     * @param callback    result callback
     */
    public void write(BleDevice device, String serviceUuid, String writeUuid, byte[] data,
                      BleWriteCallback callback) {
        mGatt.write(device, serviceUuid, writeUuid, data, callback);
    }

    /**
     * Write by batch, you can use this method to split data and deliver it to remote
     * device by batch
     *
     * @param device           remote device
     * @param serviceUuid      service uuid that the writable characteristic belongs to
     * @param writeUuid        characteristic uuid which you write data, note that the
     *                         characteristic must be writable, or it will call back onFail()
     * @param data             data
     * @param lengthPerPackage data length per package
     * @param callback         result callback
     */
    public void writeByBatch(BleDevice device, String serviceUuid, String writeUuid, byte[] data,
                             int lengthPerPackage, BleWriteByBatchCallback callback) {
        mGatt.writeByBatch(device, serviceUuid, writeUuid, data, lengthPerPackage, callback);
    }

    /**
     * Read data from specific readable characteristic
     *
     * @param device      remote device
     * @param serviceUuid service uuid that the readable characteristic belongs to
     * @param readUuid    characteristic uuid you wanna read, note that the characteristic
     *                    must be readable, or it will call back onFail()
     * @param callback    the read callback
     */
    public void read(BleDevice device, String serviceUuid, String readUuid, BleReadCallback callback) {
        mGatt.read(device, serviceUuid, readUuid, callback);
    }

    /**
     * Read the remote device rssi(Received Signal Strength Indication)
     *
     * @param device   remote device
     * @param callback result callback
     */
    public void readRssi(BleDevice device, BleRssiCallback callback) {
        mGatt.readRssi(device, callback);
    }

    /**
     * Set MTU (Maximum Transmission Unit)
     *
     * @param device   remote device
     * @param mtu      MTU value, rang from 23 to 512
     * @param callback result callback
     */
    public void setMtu(BleDevice device, int mtu, BleMtuCallback callback) {
        mGatt.setMtu(device, mtu, callback);
    }

    /**
     * Get service information which the remote device supports.
     * Note that this method will return null if this device is not connected
     *
     * @return service information
     */
    public Map<ServiceInfo, List<CharacteristicInfo>> getDeviceServices(BleDevice device) {
        return mGatt.getDeviceServices(device);
    }

    /**
     * Get connected devices list
     *
     * @return connected devices list
     */
    public List<BleDevice> getConnectedDevices() {
        return mGatt.getConnectedDevices();
    }

    /**
     * Return true if the specific remote device has connected with local device
     *
     * @param address device mac
     * @return true if local device has connected to the specific remote device
     */
    public boolean isConnected(String address) {
        if (!BluetoothAdapter.checkBluetoothAddress(address)) {
            return false;
        }
        List<BleDevice> deviceList = getConnectedDevices();
        for (BleDevice d : deviceList) {
            if (address.equals(d.address)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Return true if local device is connecting with the specific remote device
     */
    public boolean isConnecting(String address) {
        if (!BluetoothAdapter.checkBluetoothAddress(address)) {
            return false;
        }
        return mGatt.isConnecting(address);
    }

    /**
     * Once you finish bluetooth, call this method to release some resources.
     * <p>
     * Note that if you want to use BleManager again, you must call init()
     * again before that
     *
     * @see BleManager#init(Context)
     */
    public void destroy() {
        if (mGatt != null) {
            mGatt.destroy();
            mGatt = null;
        }
        if (mScan != null) {
            mScan.destroy();
            mScan = null;
        }
        unregisterBleReceiver();
        mScanOptions = null;
        mConnectOptions = null;
        mContext = null;
    }

    private void unregisterBleReceiver() {
        try {
            if (mContext == null) return;
            mContext.unregisterReceiver(BleReceiver.getInstance());
        } catch (Exception e) {
            Logger.i("unregistering BleReceiver encounters an exception: " + e.getMessage());
        }
    }

    /**
     * Return true if this device supports ble
     */
    public static boolean supportBle(Context context) {
        if (context == null) {
            throw new IllegalArgumentException("Context is null");
        }
        return BluetoothAdapter.getDefaultAdapter() != null &&
                context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE);
    }

    /**
     * Turn on local bluetooth, calling the method will show users a request dialog
     * to grant or reject,so you can get the result from Activity#onActivityResult()
     *
     * @param activity    activity, note that to get the result whether users have granted
     *                    or rejected to enable bluetooth, you should handle the method
     *                    onActivityResult() of this activity
     * @param requestCode enable bluetooth request code
     */
    public static void enableBluetooth(Activity activity, int requestCode) {
        if (activity == null || requestCode < 0) {
            return;
        }
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        if (adapter != null && !adapter.isEnabled()) {
            Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
            activity.startActivityForResult(intent, requestCode);
        }
    }

    /**
     * Turn on or off local bluetooth directly without showing users a request
     * dialog.
     * Note that a request dialog may still show when you call this method, due to
     * some special Android devices' system may have been modified by manufacturers
     *
     * @param enable enable or disable local bluetooth
     */
    public static void toggleBluetooth(boolean enable) {
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        if (adapter == null) {
            return;
        }
        if (enable) {
            adapter.enable();
        } else {
            if (adapter.isEnabled()) {
                adapter.disable();
            }
        }
    }

    /**
     * Return true if local bluetooth is enabled at present
     *
     * @return true if local bluetooth is open
     */
    public static boolean isBluetoothOn() {
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        return adapter != null && adapter.isEnabled();
    }

    public ScanOptions getScanOptions() {
        return mScanOptions == null ? ScanOptions.newInstance() : mScanOptions;
    }

    public ConnectOptions getConnectOptions() {
        return mConnectOptions == null ? ConnectOptions.newInstance() : mConnectOptions;
    }

    /**
     * Get the BluetoothGatt object of specific remote device
     *
     * @return the BluetoothGatt object, note that it will return null if connection between
     * the central device and the remote device has not started or established.
     */
    public BluetoothGatt getBluetoothGatt(String address) {
        checkBluetoothAddress(address);
        return mGatt.getBluetoothGatt(address);
    }

    private void checkBluetoothAddress(String address) {
        if (!BluetoothAdapter.checkBluetoothAddress(address)) {
            throw new IllegalArgumentException("Invalid address: " + address);
        }
    }

    private BleDevice newBleDevice(BluetoothDevice device) {
        Class<?> clasz = BleDevice.class;
        try {
            Constructor<?> constructor = clasz.getDeclaredConstructor(BluetoothDevice.class);
            constructor.setAccessible(true);
            BleDevice d = (BleDevice) constructor.newInstance(device);
            return d;
        } catch (Exception e) {
            Logger.i("Encounter an exception while creating a BleDevice object by reflection: " + e.getMessage());
            return null;
        }
    }

    public static final class ScanOptions {
        private int scanPeriod = 12000;
        private String scanDeviceName;
        private String scanDeviceAddress;
        private UUID[] scanServiceUuids;

        private ScanOptions() {

        }

        public static ScanOptions newInstance() {
            return new ScanOptions();
        }

        public ScanOptions scanPeriod(int scanPeriod) {
            if (scanPeriod > 0) {
                this.scanPeriod = scanPeriod;
            }
            return this;
        }

        public ScanOptions scanDeviceName(String deviceName) {
            scanDeviceName = deviceName;
            return this;
        }

        public ScanOptions scanDeviceAddress(String deviceAddress) {
            scanDeviceAddress = deviceAddress;
            return this;
        }

        public ScanOptions scanServiceUuids(UUID[] serviceUuids) {
            scanServiceUuids = serviceUuids;
            return this;
        }


    }

    public static final class ConnectOptions {
        private int connectTimeout = 10000;

        private ConnectOptions() {

        }

        public static ConnectOptions newInstance() {
            return new ConnectOptions();
        }

        public ConnectOptions connectTimeout(int timeout) {
            if (timeout > 0) {
                connectTimeout = timeout;
            }
            return this;
        }
    }
}