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

package com.googlecode.android_scripting.facade.bluetooth;

import android.app.Service;
import android.bluetooth.BluetoothActivityEnergyInfo;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryStats;
import android.os.Bundle;
import android.os.ParcelUuid;
import android.os.SynchronousResultReceiver;

import com.googlecode.android_scripting.Log;
import com.googlecode.android_scripting.MainThread;
import com.googlecode.android_scripting.facade.EventFacade;
import com.googlecode.android_scripting.facade.FacadeManager;
import com.googlecode.android_scripting.jsonrpc.RpcReceiver;
import com.googlecode.android_scripting.rpc.Rpc;
import com.googlecode.android_scripting.rpc.RpcDefault;
import com.googlecode.android_scripting.rpc.RpcOptional;
import com.googlecode.android_scripting.rpc.RpcParameter;

import java.time.Duration;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeoutException;

/**
 * Basic Bluetooth functions.
 */
public class BluetoothFacade extends RpcReceiver {
    private final Service mService;
    private final BroadcastReceiver mDiscoveryReceiver;
    private final IntentFilter discoveryFilter;
    private final EventFacade mEventFacade;
    private final BluetoothStateReceiver mStateReceiver;
    private static final Object mReceiverLock = new Object();
    private BluetoothStateReceiver mMultiStateReceiver;
    private final BleStateReceiver mBleStateReceiver;
    private Map<String, BluetoothConnection> connections =
            new HashMap<String, BluetoothConnection>();
    private BluetoothAdapter mBluetoothAdapter;

    public static ConcurrentHashMap<String, BluetoothDevice> DiscoveredDevices;

    public BluetoothFacade(FacadeManager manager) {
        super(manager);
        mBluetoothAdapter = MainThread.run(manager.getService(),
                new Callable<BluetoothAdapter>() {
            @Override
            public BluetoothAdapter call() throws Exception {
                return BluetoothAdapter.getDefaultAdapter();
            }
        });
        mEventFacade = manager.getReceiver(EventFacade.class);
        mService = manager.getService();

        DiscoveredDevices = new ConcurrentHashMap<String, BluetoothDevice>();
        discoveryFilter = new IntentFilter(BluetoothDevice.ACTION_FOUND);
        discoveryFilter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED);
        mDiscoveryReceiver = new DiscoveryCacheReceiver();
        mStateReceiver = new BluetoothStateReceiver();
        mMultiStateReceiver = null;
        mBleStateReceiver = new BleStateReceiver();
    }

    class DiscoveryCacheReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            if (action.equals(BluetoothDevice.ACTION_FOUND)) {
                BluetoothDevice device = intent.getParcelableExtra(
                        BluetoothDevice.EXTRA_DEVICE);
                Log.d("Found device " + device.getAlias());
                if (!DiscoveredDevices.containsKey(device.getAddress())) {
                    String name = device.getAlias();
                    if (name != null) {
                        DiscoveredDevices.put(device.getAlias(), device);
                    }
                    DiscoveredDevices.put(device.getAddress(), device);
                }
            } else if (action.equals(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) {
                mEventFacade.postEvent("BluetoothDiscoveryFinished", new Bundle());
                mService.unregisterReceiver(mDiscoveryReceiver);
            }
        }
    }

    class BluetoothStateReceiver extends BroadcastReceiver {

        private final boolean mIsMultiBroadcast;

        public BluetoothStateReceiver() {
            mIsMultiBroadcast = false;
        }

        public BluetoothStateReceiver(boolean isMultiBroadcast) {
            mIsMultiBroadcast = isMultiBroadcast;
        }

        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) {
                final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1);
                Bundle msg = new Bundle();
                if (state == BluetoothAdapter.STATE_ON) {
                    msg.putString("State", "ON");
                    mEventFacade.postEvent("BluetoothStateChangedOn", msg.clone());
                    if (!mIsMultiBroadcast) {
                        mService.unregisterReceiver(mStateReceiver);
                    }
                } else if(state == BluetoothAdapter.STATE_OFF) {
                    msg.putString("State", "OFF");
                    mEventFacade.postEvent("BluetoothStateChangedOff", msg.clone());
                    if (!mIsMultiBroadcast) {
                        mService.unregisterReceiver(mStateReceiver);
                    }
                }
            }
        }
    }

    class BleStateReceiver extends BroadcastReceiver {

        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            if (action.equals(BluetoothAdapter.ACTION_BLE_STATE_CHANGED)) {
                int state = mBluetoothAdapter.getLeState();
                if (state == BluetoothAdapter.STATE_BLE_ON) {
                    mEventFacade.postEvent("BleStateChangedOn", new Bundle());
                    mService.unregisterReceiver(mBleStateReceiver);
                } else if (state == BluetoothAdapter.STATE_OFF) {
                    mEventFacade.postEvent("BleStateChangedOff", new Bundle());
                    mService.unregisterReceiver(mBleStateReceiver);
                }
            }
        }
    }


    public static boolean deviceMatch(BluetoothDevice device, String deviceID) {
        return deviceID.equals(device.getAlias()) || deviceID.equals(
                device.getAddress());
    }

    /**
     * Get Bluetooth device.
     * @param devices - HashMap of Device Address and Bluetooth device name.
     * @param device - name of the device.
     * @return the device name if it exits.
     */
    public static <T> BluetoothDevice getDevice(
            ConcurrentHashMap<String, T> devices, String device)
            throws Exception {
        if (devices.containsKey(device)) {
            return (BluetoothDevice) devices.get(device);
        } else {
            throw new Exception("Can't find device " + device);
        }
    }

    /**
     * Get Bluetooth device.
     * @param devices - Collection of device IDs.
     * @param deviceID - ID of the desired device.
     * @return the Bluetooth device if the device ID is matched.
     */
    public static BluetoothDevice getDevice(
            Collection<BluetoothDevice> devices, String deviceID)
            throws Exception {
        Log.d("Looking for " + deviceID);
        for (BluetoothDevice bd : devices) {
            Log.d(bd.getAlias() + " " + bd.getAddress());
            if (deviceMatch(bd, deviceID)) {
                Log.d("Found match " + bd.getAlias() + " " + bd.getAddress());
                return bd;
            }
        }
        throw new Exception("Can't find device " + deviceID);
    }

    /**
     * Verify device existence.
     * @param devices - Collection of device IDs.
     * @param deviceID - ID of the desired device.
     * @return if the device Exists or not.
     */
    public static boolean deviceExists(
            Collection<BluetoothDevice> devices, String deviceID) {
        for (BluetoothDevice bd : devices) {
            if (deviceMatch(bd, deviceID)) {
                Log.d("Found match " + bd.getAlias() + " " + bd.getAddress());
                return true;
            }
        }
        return false;
    }

    @Rpc(description = "Requests that the device be made connectable.")
    public void bluetoothMakeConnectable() {
        mBluetoothAdapter
                .setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE);
    }

    @Rpc(description = "Requests that the device be discoverable for Bluetooth connections.")
    public void bluetoothMakeDiscoverable(
            @RpcParameter(name = "duration",
                          description = "period of time, in milliseconds,"
                                      + "during which the device should be discoverable")
            @RpcDefault("300000")
            Long duration) {
        Duration finalDuration = Duration.ofMillis(duration);
        if (finalDuration.toSeconds() <= Integer.MAX_VALUE) {
            mBluetoothAdapter.setDiscoverableTimeout(finalDuration);
            mBluetoothAdapter.setScanMode(
                    BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE);
            Log.d("Making discoverable for " + duration + " milliseconds.\n");
        } else {
            Log.e("setScanMode: Duration in seconds outside of the bounds of an int");
            throw new IllegalArgumentException("Duration not in bounds. In seconds, the "
                    + "duration must be in the range of an int");
        }
    }

    @Rpc(description = "Requests that the device be not discoverable.")
    public void bluetoothMakeUndiscoverable() {
        Log.d("Making undiscoverable\n");
        mBluetoothAdapter.setScanMode(BluetoothAdapter.SCAN_MODE_NONE);
    }

    @Rpc(description = "Queries a remote device for it's name or null if it can't be resolved")
    public String bluetoothGetRemoteDeviceName(
            @RpcParameter(name = "address", description = "Bluetooth Address For Target Device")
            String address) {
        try {
            BluetoothDevice mDevice;
            mDevice = mBluetoothAdapter.getRemoteDevice(address);
            return mDevice.getName();
        } catch (Exception e) {
            return null;
        }
    }

    @Rpc(description = "Fetch UUIDS with SDP")
    public boolean bluetoothFetchUuidsWithSdp(
            @RpcParameter(name = "address", description = "Bluetooth Address For Target Device")
            String address) {
        try {
            BluetoothDevice mDevice;
            mDevice = mBluetoothAdapter.getRemoteDevice(address);
            return mDevice.fetchUuidsWithSdp();
        } catch (Exception e) {
            return false;
        }
    }

    @Rpc(description = "Get local Bluetooth device name")
    public String bluetoothGetLocalName() {
        return mBluetoothAdapter.getName();
    }

    @Rpc(description = "Sets the Bluetooth visible device name", returns = "true on success")
    public boolean bluetoothSetLocalName(
        @RpcParameter(name = "name", description = "New local name")
        String name) {
        return mBluetoothAdapter.setName(name);
    }

    @Rpc(description = "Returns the hardware address of the local Bluetooth adapter. ")
    public String bluetoothGetLocalAddress() {
        return mBluetoothAdapter.getAddress();
    }

    @Rpc(description = "Returns the UUIDs supported by local Bluetooth adapter.")
    public ParcelUuid[] bluetoothGetLocalUuids() {
        List<ParcelUuid> uuidsList = mBluetoothAdapter.getUuidsList();
        ParcelUuid[] uuidsArray = new ParcelUuid[uuidsList.size()];
        uuidsList.toArray(uuidsArray);
        return uuidsArray;
    }

    @Rpc(description = "Gets the scan mode for the local dongle.\r\n" + "Return values:\r\n"
            + "\t-1 when Bluetooth is disabled.\r\n"
            + "\t0 if non discoverable and non connectable.\r\n"
            + "\r1 connectable non discoverable." + "\r3 connectable and discoverable.")
    public int bluetoothGetScanMode() {
        if (mBluetoothAdapter.getState() == BluetoothAdapter.STATE_OFF
                || mBluetoothAdapter.getState() == BluetoothAdapter.STATE_TURNING_OFF) {
            return -1;
        }
        switch (mBluetoothAdapter.getScanMode()) {
            case BluetoothAdapter.SCAN_MODE_NONE:
                return 0;
            case BluetoothAdapter.SCAN_MODE_CONNECTABLE:
                return 1;
            case BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE:
                return 3;
            default:
                return mBluetoothAdapter.getScanMode() - 20;
        }
    }

    @Rpc(description = "Return the set of BluetoothDevice that are paired to the local adapter.")
    public Set<BluetoothDevice> bluetoothGetBondedDevices() {
        return mBluetoothAdapter.getBondedDevices();
    }

    @Rpc(description = "Checks Bluetooth state.", returns = "True if Bluetooth is enabled.")
    public Boolean bluetoothCheckState() {
        return mBluetoothAdapter.isEnabled();
    }

    @Rpc(description = "Factory reset bluetooth settings.", returns = "True if successful.")
    public boolean bluetoothFactoryReset() {
        return mBluetoothAdapter.clearBluetooth();
    }

    @Rpc(description = "Toggle Bluetooth on and off.", returns = "True if Bluetooth is enabled.")
    public Boolean bluetoothToggleState(@RpcParameter(name = "enabled")
    @RpcOptional
    Boolean enabled,
            @RpcParameter(name = "prompt",
                          description = "Prompt the user to confirm changing the Bluetooth state.")
            @RpcDefault("false")
            Boolean prompt) {
        mService.registerReceiver(mStateReceiver,
                                  new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
        if (enabled == null) {
            enabled = !bluetoothCheckState();
        }
        if (enabled) {
            return mBluetoothAdapter.enable();
        } else {
            shutdown();
            return mBluetoothAdapter.disable();
        }
    }


    @Rpc(description = "Start the remote device discovery process. ",
         returns = "true on success, false on error")
    public Boolean bluetoothStartDiscovery() {
        DiscoveredDevices.clear();
        mService.registerReceiver(mDiscoveryReceiver, discoveryFilter);
        return mBluetoothAdapter.startDiscovery();
    }

    @Rpc(description = "Cancel the current device discovery process.",
         returns = "true on success, false on error")
    public Boolean bluetoothCancelDiscovery() {
        try {
            mService.unregisterReceiver(mDiscoveryReceiver);
        } catch (IllegalArgumentException e) {
            Log.d("IllegalArgumentExeption found when trying to unregister reciever");
        }
        return mBluetoothAdapter.cancelDiscovery();
    }

    @Rpc(description = "If the local Bluetooth adapter is currently"
                     + "in the device discovery process.")
    public Boolean bluetoothIsDiscovering() {
        return mBluetoothAdapter.isDiscovering();
    }

    @Rpc(description = "Get all the discovered bluetooth devices.")
    public Collection<BluetoothDevice> bluetoothGetDiscoveredDevices() {
        while (bluetoothIsDiscovering())
            ;
        return DiscoveredDevices.values();
    }

    @Rpc(description = "Get Bluetooth controller activity energy info.")
    public String bluetoothGetControllerActivityEnergyInfo() {
        SynchronousResultReceiver receiver = new SynchronousResultReceiver();
        mBluetoothAdapter.requestControllerActivityEnergyInfo(
                new Executor() {
                    @Override
                    public void execute(Runnable runnable) {
                        runnable.run();
                    }
                },
                new BluetoothAdapter.OnBluetoothActivityEnergyInfoCallback() {
                    @Override
                    public void onBluetoothActivityEnergyInfoAvailable(
                            BluetoothActivityEnergyInfo info) {
                        Bundle bundle = new Bundle();
                        bundle.putParcelable(
                                BatteryStats.RESULT_RECEIVER_CONTROLLER_KEY, info);
                        receiver.send(0, bundle);
                    }

                    @Override
                    public void onBluetoothActivityEnergyInfoError(int errorCode) {
                        Bundle bundle = new Bundle();
                        bundle.putParcelable(
                                BatteryStats.RESULT_RECEIVER_CONTROLLER_KEY, null);
                        receiver.send(0, bundle);
                    }
                }
        );
        try {
            SynchronousResultReceiver.Result result = receiver.awaitResult(1000);
            if (result.bundle != null) {
                return result.bundle.getParcelable(BatteryStats.RESULT_RECEIVER_CONTROLLER_KEY)
                    .toString();
            }
        } catch (TimeoutException e) {
            Log.e("getControllerActivityEnergyInfo timed out");
        }
        return null;
    }

    @Rpc(description = "Return true if hardware has entries" +
            "available for matching beacons.")
    public boolean bluetoothIsHardwareTrackingFiltersAvailable() {
        return mBluetoothAdapter.isHardwareTrackingFiltersAvailable();
    }

    /**
     * Return true if LE 2M PHY feature is supported.
     *
     * @return true if chipset supports LE 2M PHY feature
     */
    @Rpc(description = "Return true if LE 2M PHY feature is supported")
    public boolean bluetoothIsLe2MPhySupported() {
        return mBluetoothAdapter.isLe2MPhySupported();
    }

    /**
     * Return true if LE Coded PHY feature is supported.
     *
     * @return true if chipset supports LE Coded PHY feature
     */
    @Rpc(description = "Return true if LE Coded PHY feature is supported")
    public boolean bluetoothIsLeCodedPhySupported() {
        return mBluetoothAdapter.isLeCodedPhySupported();
    }

    /**
     * Return true if LE Extended Advertising feature is supported.
     *
     * @return true if chipset supports LE Extended Advertising feature
     */
    @Rpc(description = "Return true if LE Extended Advertising is supported")
    public boolean bluetoothIsLeExtendedAdvertisingSupported() {
        return mBluetoothAdapter.isLeExtendedAdvertisingSupported();
    }

    /**
     * Return true if LE Periodic Advertising feature is supported.
     *
     * @return true if chipset supports LE Periodic Advertising feature
     */
    @Rpc(description = "Return true if LE Periodic Advertising is supported")
    public boolean bluetoothIsLePeriodicAdvertisingSupported() {
        return mBluetoothAdapter.isLePeriodicAdvertisingSupported();
    }

    /**
     * Return the maximum LE advertising data length,
     * if LE Extended Advertising feature is supported.
     *
     * @return the maximum LE advertising data length.
     */
    @Rpc(description = "Return the maximum LE advertising data length")
    public int bluetoothGetLeMaximumAdvertisingDataLength() {
        return mBluetoothAdapter.getLeMaximumAdvertisingDataLength();
    }

    @Rpc(description = "Gets the current state of LE.")
    public int bluetoothGetLeState() {
        return mBluetoothAdapter.getLeState();
    }

    @Rpc(description = "Enables BLE functionalities.")
    public boolean bluetoothEnableBLE() {
        mService.registerReceiver(mBleStateReceiver,
            new IntentFilter(BluetoothAdapter.ACTION_BLE_STATE_CHANGED));
        return mBluetoothAdapter.enableBLE();
    }

    @Rpc(description = "Disables BLE functionalities.")
    public boolean bluetoothDisableBLE() {
        mService.registerReceiver(mBleStateReceiver,
            new IntentFilter(BluetoothAdapter.ACTION_BLE_STATE_CHANGED));
        return mBluetoothAdapter.disableBLE();
    }

    @Rpc(description = "Listen for a Bluetooth LE State Change.")
    public boolean bluetoothListenForBleStateChange() {
        mService.registerReceiver(mBleStateReceiver,
            new IntentFilter(BluetoothAdapter.ACTION_BLE_STATE_CHANGED));
        return true;
    }

    @Rpc(description = "Stop Listening for a Bluetooth LE State Change.")
    public boolean bluetoothStopListeningForBleStateChange() {
        mService.unregisterReceiver(mBleStateReceiver);
        return true;
    }

    @Rpc(description = "Listen for Bluetooth State Changes.")
    public boolean bluetoothStartListeningForAdapterStateChange() {
        synchronized (mReceiverLock) {
            if (mMultiStateReceiver != null) {
                Log.e("Persistent Bluetooth Receiver State Change Listener Already Active");
                return false;
            }
            mMultiStateReceiver = new BluetoothStateReceiver(true);
            mService.registerReceiver(mMultiStateReceiver,
                    new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
        }
        return true;
    }

    @Rpc(description = "Stop Listening for Bluetooth State Changes.")
    public boolean bluetoothStopListeningForAdapterStateChange() {
        synchronized (mReceiverLock) {
            if (mMultiStateReceiver == null) {
                Log.d("No Persistent Bluetooth Receiever State Change Listener Found to Stop");
                return false;
            }
            mService.unregisterReceiver(mMultiStateReceiver);
            mMultiStateReceiver = null;
        }
        return true;
    }

    @Override
    public void shutdown() {
        for (Map.Entry<String,
                BluetoothConnection> entry : connections.entrySet()) {
            entry.getValue().stop();
        }
        if (mMultiStateReceiver != null ) bluetoothStopListeningForAdapterStateChange();
        connections.clear();
    }
}
