/*
 *  Copyright 2016 Google Inc. All Rights Reserved.
 *
 *  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.google.android.apps.forscience.ble;

import android.app.Service;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Binder;
import android.os.IBinder;
import androidx.annotation.VisibleForTesting;
import androidx.collection.ArraySet;
import android.util.ArrayMap;
import android.util.Log;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

/** Service dealing with the BLE gory details. */
public class MyBleService extends Service {
  private static String TAG = "MyBleService";
  private static final int SERVICES_RETRY_COUNT = 3;
  private static final boolean DEBUG = false;

  static LocalBroadcastManager getBroadcastManager(Context context) {
    // For security, only use local broadcasts (See b/32803250)
    return LocalBroadcastManager.getInstance(context);
  }

  /** The local binder for this service. */
  class LocalBinder extends Binder {
    public MyBleService getService() {
      return MyBleService.this;
    }
  }

  public static String DATA = "data";
  public static String UUID = "uuid";
  public static String FLAGS = "flags";
  public static String INT_PARAM = "int_param";

  private BluetoothManager bluetoothManager;
  private BluetoothAdapter btAdapter;

  private Map<String, BluetoothGatt> addressToGattClient =
      Collections.synchronizedMap(new LinkedHashMap<String, BluetoothGatt>());

  private Set<String> outstandingServiceDiscoveryAddresses = new ArraySet<>();

  // GATT callbacks
  private BluetoothGattCallback gattCallbacks =
      new BluetoothGattCallback() {
        Map<String, Integer> connectionStatuses = new ArrayMap<>();

        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
          String address = getAddressFromGatt(gatt);
          if (DEBUG) Log.d(TAG, "CONNECTION CHANGED FOR " + address + " : " + newState);

          // On ChromeOS (and maybe other platforms?), onConnectionStateChange can be called
          // multiple times without any actual corresponding state change.  This confuses our
          // (very brittle) downstream code, so filter the duplicates out here.  See
          // b/31741822 for further discussion.
          boolean isActualChange =
              !(connectionStatuses.containsKey(address)
                  && connectionStatuses.get(address) == newState);
          connectionStatuses.put(address, newState);

          if (status != BluetoothGatt.GATT_SUCCESS) {
            sendGattBroadcast(address, BleEvents.GATT_CONNECT_FAIL, null);
            addressToGattClient.remove(address);
            gatt.close();
            return;
          }

          if (newState == BluetoothProfile.STATE_CONNECTED) {
            // TODO: extract testable code here
            if (isActualChange) {
              sendGattBroadcast(address, BleEvents.GATT_CONNECT, null);
            }
            return;
          }
          if (newState == BluetoothProfile.STATE_DISCONNECTED) {
            sendGattBroadcast(address, BleEvents.GATT_DISCONNECT, null);
            addressToGattClient.remove(address);
            gatt.close();
            return;
          }
          Log.e(TAG, "Gatt - unexpected connection state: " + newState);
        }

        @Override
        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
          String address = getAddressFromGatt(gatt);
          outstandingServiceDiscoveryAddresses.remove(address);
          if (status == BluetoothGatt.GATT_SUCCESS) {
            if (DEBUG) Log.d(TAG, "Sending the action: " + BleEvents.SERVICES_OK);
            sendServiceDiscoveryIntent(MyBleService.this, address, SERVICES_RETRY_COUNT);
          } else {
            sendGattBroadcast(address, BleEvents.SERVICES_FAIL, null);
          }
        }

        @Override
        public void onCharacteristicChanged(
            BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
          if (DEBUG) Log.d(TAG, "Got notification from " + characteristic.getUuid());
          sendGattBroadcast(getAddressFromGatt(gatt), BleEvents.CHAR_CHANGED, characteristic);
        }

        @Override
        public void onCharacteristicRead(
            BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
          // Characteristic value will be stored in the intent which will be extract from
          // the broadcast message (see sendGattBroadcast and BleFlow.BroadcastReceiver).
          if (DEBUG) {
            Log.d(
                TAG,
                "Characteristic read result: "
                    + characteristic.getUuid()
                    + " - "
                    + (status == BluetoothGatt.GATT_SUCCESS));
            Log.d(TAG, "Characteristic value: " + characteristic.getStringValue(0).toString());
          }

          sendGattBroadcast(
              getAddressFromGatt(gatt),
              status == BluetoothGatt.GATT_SUCCESS
                  ? BleEvents.READ_CHAR_OK
                  : BleEvents.READ_CHAR_FAIL,
              characteristic);
        }

        @Override
        public void onCharacteristicWrite(
            BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
          if (DEBUG)
            Log.d(
                TAG,
                "Characteristic write result: "
                    + characteristic.getUuid()
                    + " - "
                    + (status == BluetoothGatt.GATT_SUCCESS));
          sendGattBroadcast(
              getAddressFromGatt(gatt),
              status == BluetoothGatt.GATT_SUCCESS
                  ? BleEvents.WRITE_CHAR_OK
                  : BleEvents.WRITE_CHAR_FAIL,
              characteristic);
        }

        @Override
        public void onDescriptorRead(
            BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
          sendGattBroadcast(
              getAddressFromGatt(gatt),
              status == BluetoothGatt.GATT_SUCCESS
                  ? BleEvents.READ_DESC_OK
                  : BleEvents.READ_DESC_FAIL,
              null);
        }

        @Override
        public void onDescriptorWrite(
            BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
          sendGattBroadcast(
              getAddressFromGatt(gatt),
              status == BluetoothGatt.GATT_SUCCESS
                  ? BleEvents.WRITE_DESC_OK
                  : BleEvents.WRITE_DESC_FAIL,
              null);
        }
      };

  public static void sendServiceDiscoveryIntent(Context context, String address, int retriesLeft) {
    Intent newIntent = BleEvents.createIntent(BleEvents.SERVICES_OK, address);
    newIntent.putExtra(INT_PARAM, retriesLeft);
    getBroadcastManager(context).sendBroadcast(newIntent);
  }

  @VisibleForTesting
  protected String getAddressFromGatt(BluetoothGatt gatt) {
    return gatt.getDevice().getAddress();
  }

  private final IBinder binder = new LocalBinder();

  @VisibleForTesting
  protected void sendGattBroadcast(
      String address, String gattAction, BluetoothGattCharacteristic characteristic) {
    if (DEBUG) Log.d(TAG, "Sending the action: " + gattAction);
    Intent newIntent = BleEvents.createIntent(gattAction, address);
    if (characteristic != null) {
      newIntent.putExtra(UUID, characteristic.getUuid().toString());
      newIntent.putExtra(FLAGS, characteristic.getProperties());
      newIntent.putExtra(DATA, characteristic.getValue());
    }
    getBroadcastManager(this).sendBroadcast(newIntent);
  }

  @Override
  public int onStartCommand(Intent intent, int flags, int startId) {
    if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
      Intent newIntent = new Intent(BleEvents.BLE_UNSUPPORTED);
      getBroadcastManager(this).sendBroadcast(newIntent);
      return START_NOT_STICKY;
    }

    if (bluetoothManager == null) {
      bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
    }
    if (btAdapter == null) {
      btAdapter = bluetoothManager.getAdapter();
    }
    if (!checkBleEnabled()) {
      stopSelf();
      return START_NOT_STICKY;
    }

    return START_STICKY;
  }

  public boolean checkBleEnabled() {
    if (!isBleEnabled()) {
      Intent newIntent = new Intent(BleEvents.BLE_DISABLED);
      getBroadcastManager(this).sendBroadcast(newIntent);
      if (DEBUG) Log.d(TAG, "sent intent BLE_DISABLED");
      return false;
    }

    return true;
  }

  private boolean isBleEnabled() {
    return btAdapter != null && btAdapter.isEnabled();
  }

  public boolean connect(String address) {
    if (btAdapter == null) {
      // Not sure how we could get here, but it happens (b/36738130), so flag an error
      // instead of crashing.
      return false;
    }
    BluetoothDevice device = btAdapter.getRemoteDevice(address);
    //  Explicitly check if Ble is enabled, otherwise it attempts a connection
    //  that never timesout even though it should.
    if (device == null || !isBleEnabled()) {
      return false;
    }
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    int connectionState = bluetoothManager.getConnectionState(device, BluetoothProfile.GATT);
    if (bluetoothGatt != null && connectionState != BluetoothProfile.STATE_CONNECTED) {
      return bluetoothGatt.connect();
    }
    if (bluetoothGatt != null && connectionState == BluetoothProfile.STATE_CONNECTED) {
      sendGattBroadcast(address, BleEvents.GATT_CONNECT, null);
      return true;
    }

    if (bluetoothGatt != null) {
      bluetoothGatt.close();
    }
    bluetoothGatt =
        device.connectGatt(
            this,
            false, // autoConnect = false
            gattCallbacks);
    addressToGattClient.put(address, bluetoothGatt);
    return true;
  }

  public void disconnectDevice(String address) {
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    if (btAdapter == null || address == null || bluetoothGatt == null) {
      // Broadcast the disconnect so BleFlow doesn't hang waiting for it; something else
      // already disconnected us in this case.
      sendGattBroadcast(address, BleEvents.GATT_DISCONNECT, null);
      return;
    }
    BluetoothDevice device = btAdapter.getRemoteDevice(address);
    int bleState = bluetoothManager.getConnectionState(device, BluetoothProfile.GATT);
    if (bleState != BluetoothProfile.STATE_DISCONNECTED
        && bleState != BluetoothProfile.STATE_DISCONNECTING) {
      bluetoothGatt.disconnect();
    } else {
      bluetoothGatt.close();
      addressToGattClient.remove(address);
      sendGattBroadcast(address, BleEvents.GATT_DISCONNECT, null);
    }
  }

  void resetGatt() {
    for (BluetoothGatt bluetoothGatt : addressToGattClient.values()) {
      bluetoothGatt.close();
    }
  }

  @Override
  public void onDestroy() {
    resetGatt();
    super.onDestroy();
  }

  @Override
  public IBinder onBind(Intent intent) {
    return binder;
  }

  public boolean discoverServices(String address) {
    if (outstandingServiceDiscoveryAddresses.contains(address)) {
      return addressToGattClient.containsKey(address);
    }
    outstandingServiceDiscoveryAddresses.add(address);
    return internalDiscoverServices(address);
  }

  @VisibleForTesting
  protected boolean internalDiscoverServices(String address) {
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    return bluetoothGatt != null && bluetoothGatt.discoverServices();
  }

  BluetoothGattService getService(String address, UUID serviceId) {
    if (DEBUG) Log.d(TAG, "lookup for service: " + serviceId);
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    return bluetoothGatt == null ? null : bluetoothGatt.getService(serviceId);
  }

  /**
   * FOR DEBUGGING ONLY. This should never be called from production code; we don't want this data
   * in our logs.
   */
  @SuppressWarnings("UnusedDeclaration")
  public void printServices(String address) {
    if (!DEBUG) {
      return;
    }
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    if (bluetoothGatt == null) {
      Log.d(TAG, "No connection found for: " + address);
      return;
    }
    for (BluetoothGattService service : bluetoothGatt.getServices()) {
      Log.d(TAG, "Service ================================");
      Log.d(TAG, "Service UUID: " + service.getUuid());
      Log.d(TAG, "Service Type: " + service.getType());

      for (BluetoothGattCharacteristic charact : service.getCharacteristics()) {
        Log.d(TAG, "Charact UUID: " + charact.getUuid());
        Log.d(TAG, "Charact prop: " + charact.getProperties());

        if (charact.getValue() != null) {
          Log.d(TAG, "Charact Value: " + new String(charact.getValue()));
        }
      }
    }
  }

  void readValue(String address, BluetoothGattCharacteristic theCharacteristic) {
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    if (bluetoothGatt == null) {
      Log.w(TAG, "No connection found for: " + address);
      sendGattBroadcast(address, BleEvents.READ_CHAR_FAIL, null);
      return;
    }
    bluetoothGatt.readCharacteristic(theCharacteristic);
  }

  void writeValue(String address, BluetoothGattCharacteristic theCharacteristic, byte[] value) {
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    if (bluetoothGatt == null) {
      Log.w(TAG, "No connection found for: " + address);
      sendGattBroadcast(address, BleEvents.WRITE_CHAR_FAIL, null);
      return;
    }
    theCharacteristic.setValue(value);
    bluetoothGatt.writeCharacteristic(theCharacteristic);
  }

  public void writeValue(String address, BluetoothGattDescriptor descriptor, byte[] value) {
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    if (bluetoothGatt == null) {
      Log.w(TAG, "No connection found for: " + address);
      sendGattBroadcast(address, BleEvents.WRITE_DESC_FAIL, null);
      return;
    }

    if (!descriptor.setValue(value) || !bluetoothGatt.writeDescriptor(descriptor)) {
      sendGattBroadcast(address, BleEvents.WRITE_DESC_FAIL, descriptor.getCharacteristic());
    }
  }

  boolean setNotificationsFor(
      String address, BluetoothGattCharacteristic characteristic, boolean enable) {
    BluetoothGatt bluetoothGatt = addressToGattClient.get(address);
    if (bluetoothGatt == null) {
      Log.w(TAG, "No connection found for: " + address);
      return false;
    }

    return bluetoothGatt.setCharacteristicNotification(characteristic, enable);
  }
}