/*
 * Copyright (C) 2018 Google Inc.
 *
 * 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.accessibility.switchaccess.setupwizard.bluetooth;

import android.Manifest;
import android.Manifest.permission;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothClass;
import android.bluetooth.BluetoothDevice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import androidx.annotation.RequiresPermission;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.google.android.accessibility.switchaccess.setupwizard.bluetooth.ComparableBluetoothDevice.BluetoothConnectionState;
import com.google.android.accessibility.switchaccess.setupwizard.bluetooth.ComparableBluetoothDevice.BluetoothDeviceActionListener;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Set;

/**
 * Class that manages and shares bluetooth events with a list of {@link BluetoothCallback} classes.
 */
public class BluetoothEventManager {

  /* The context associated with this BluetoothEventManager. */
  private final Context context;

  /*
   * Request code passed to {@link Activity#startActivityForResult} when requesting to enable
   * bluetooth in order to receive a callback when bluetooth is successfully enabled.
   */
  public static final int REQUEST_ENABLE_BLUETOOTH = 100;

  /*
   * Request code passed to {@link Activity#startActivityForResult} when requesting {@link
   * permission#ACCESS_COARSE_LOCATION}.
   */
  private static final int REQUEST_ACCESS_COARSE_LOCATION = 200;

  /* List of {@link BluetoothCallback} classes that have been registered to the
   * BluetoothEventManager to receive bluetooth-related events. */
  private final Collection<BluetoothCallback> bluetoothCallbacks = new ArrayList<>();

  /* The local bluetooth adapter, used for initiating device discovery and getting paired
   * devices. */
  private BluetoothAdapter bluetoothAdapter;

  /* Boolean used to keep track of whether or not the discovery and pairing broadcast receiver has
   * been registered to prevent crashes from improper registration handling. */
  private boolean isDiscoveryAndPairingReceiverRegistered = false;

  /* The listener that will be notified of the actions performed by the user when connecting a
   * bluetooth device.
   */
  private final BluetoothDeviceActionListener bluetoothDeviceActionListener;

  /* The BroadcastReceiver will cause a crash if receiver registration isn't handled properly.
   * Attempting to register an already registered receiver or to unregister an unregistered
   * broadcast receiver will cause a crash, and there's not a good way to check if the receiver has
   * been previously registered. */
  private final BroadcastReceiver discoveryAndPairingReceiver =
      new BroadcastReceiver() {
        @RequiresPermission(allOf = {permission.BLUETOOTH, permission.BLUETOOTH_ADMIN})
        @Override
        public void onReceive(Context context, Intent intent) {
          String action = intent.getAction();
          BluetoothDevice bluetoothDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
          if (bluetoothDevice == null) {
            return;
          }
          ComparableBluetoothDevice comparableBluetoothDevice =
              new ComparableBluetoothDevice(
                  context, bluetoothAdapter, bluetoothDevice, bluetoothDeviceActionListener);
          if (BluetoothDevice.ACTION_NAME_CHANGED.equals(action)
              || BluetoothDevice.ACTION_FOUND.equals(action)) {
            String bluetoothDeviceName = intent.getStringExtra(BluetoothDevice.EXTRA_NAME);
            comparableBluetoothDevice.setName(bluetoothDeviceName);
            if (action.equals(BluetoothDevice.ACTION_FOUND)) {
              BluetoothClass bluetoothDeviceClass =
                  intent.getParcelableExtra(BluetoothDevice.EXTRA_CLASS);
              comparableBluetoothDevice.setBluetoothClass(bluetoothDeviceClass);
            }

            /* Don't add a device if it's already been bonded (paired) to the device. */
            if (bluetoothDevice.getBondState() != BluetoothDevice.BOND_BONDED) {
              short rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE);
              comparableBluetoothDevice.setRssi(rssi);
              dispatchDeviceDiscoveredEvent(comparableBluetoothDevice);
              // TODO: Remove available devices from adapter if they become
              // unavailable. This will most likely be unable to be addressed without API changes.
            } else {
              dispatchDevicePairedEvent(comparableBluetoothDevice);
            }
          } else if (BluetoothDevice.ACTION_ACL_CONNECTED.equals(action)) {
            if (bluetoothDevice.getBondState() == BluetoothDevice.BOND_BONDED) {
              comparableBluetoothDevice.setConnectionState(BluetoothConnectionState.CONNECTED);
              dispatchDeviceConnectionStateChangedEvent(comparableBluetoothDevice);
            }
          } else if (BluetoothDevice.ACTION_ACL_DISCONNECTED.equals(action)) {
            if (bluetoothDevice.getBondState() == BluetoothDevice.BOND_BONDED) {
              comparableBluetoothDevice.setConnectionState(BluetoothConnectionState.UNKNOWN);
              dispatchDeviceConnectionStateChangedEvent(comparableBluetoothDevice);
            }
          } else if (BluetoothDevice.ACTION_BOND_STATE_CHANGED.equals(action)) {
            if (bluetoothDevice.getBondState() == BluetoothDevice.BOND_BONDED) {
              comparableBluetoothDevice.setConnectionState(BluetoothConnectionState.CONNECTED);
              dispatchDevicePairedEvent(comparableBluetoothDevice);
            } else if (bluetoothDevice.getBondState() == BluetoothDevice.BOND_NONE) {
              /* The call to #createBond has completed, but the Bluetooth device isn't bonded, so
               * set the connection state to unavailable. */
              comparableBluetoothDevice.setConnectionState(BluetoothConnectionState.UNAVAILABLE);
              dispatchDeviceConnectionStateChangedEvent(comparableBluetoothDevice);
            }

            /* If we canceled discovery before beginning the pairing process, resume discovery after
             * {@link BluetoothDevice#createBond} finishes. */
            if (bluetoothDevice.getBondState() != BluetoothDevice.BOND_BONDING
                && !bluetoothAdapter.isDiscovering()) {
              bluetoothAdapter.startDiscovery();
            }
          }
        }
      };

  /* Boolean used to keep track of whether or not the state broadcast receiver has been registered
   * to prevent crashes from improper registration handling. */
  private boolean isStateReceiverRegistered = false;

  /* Bluetooth state change events and bluetooth device discovery and pairing events have different
   * lifecycles, so it is necessary to separate the two to avoid crashes. */
  private final BroadcastReceiver stateReceiver =
      new BroadcastReceiver() {
        @RequiresPermission(allOf = {permission.BLUETOOTH, permission.BLUETOOTH_ADMIN})
        @Override
        public void onReceive(Context context, Intent intent) {
          String action = intent.getAction();
          if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(action)) {
            if (bluetoothAdapter.getState() == BluetoothAdapter.STATE_OFF) {
              dispatchBluetoothStateChangeEvent(BluetoothAdapter.STATE_OFF);
            } else if (bluetoothAdapter.getState() == BluetoothAdapter.STATE_ON) {
              dispatchBluetoothStateChangeEvent(BluetoothAdapter.STATE_ON);
              updatePairedDevicesAndInitiateDiscovery();
            }
          }
        }
      };

  @RequiresPermission(allOf = {permission.BLUETOOTH, permission.BLUETOOTH_ADMIN})
  public BluetoothEventManager(
      Context context, BluetoothDeviceActionListener bluetoothDeviceActionListener) {
    this.context = context;

    bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
    /* Cases where a device doesn't support Bluetooth should be handled before initializing
     * BluetoothEventManager. */

    this.bluetoothDeviceActionListener = bluetoothDeviceActionListener;
  }

  /**
   * Returns the local bluetooth adapter associated with this bluetooth event manager.
   *
   * @return the associated bluetooth adapter
   */
  public BluetoothAdapter getBluetoothAdapter() {
    return bluetoothAdapter;
  }

  /**
   * Sets the bluetooth adapter associated with this bluetooth event manager.
   *
   * @param bluetoothAdapter the bluetooth adapter for the bluetooth event manager to use
   */
  @VisibleForTesting
  void setBluetoothAdapter(BluetoothAdapter bluetoothAdapter) {
    this.bluetoothAdapter = bluetoothAdapter;
  }

  /**
   * Initiates discovery with the associated bluetooth adapter and registers a {@link
   * BroadcastReceiver} to listen to bluetooth device discovery and pairing events.
   */
  @RequiresPermission(allOf = {permission.BLUETOOTH, permission.BLUETOOTH_ADMIN})
  public void initiateDiscovery() {
    if (!bluetoothAdapter.isDiscovering()) {
      if (!isDiscoveryAndPairingReceiverRegistered) {
        IntentFilter intentFilter = new IntentFilter(BluetoothDevice.ACTION_NAME_CHANGED);
        intentFilter.addAction(BluetoothDevice.ACTION_FOUND);
        intentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);
        intentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED);
        intentFilter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
        intentFilter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
        context.registerReceiver(discoveryAndPairingReceiver, intentFilter);
        isDiscoveryAndPairingReceiverRegistered = true;
      }

      /* Check for the ACCESS_COARSE_LOCATION permission. If the user denies this permission, no
       * ACTION_FOUND broadcasts will be received by the discoveryAndPairingReceiver.
       * ACTION_NAME_CHANGED broadcasts will still be received, though there may be a delay before
       * users first see discovered devices.
       *
       * Applications can handle cases when this permission is denied by overriding {@link
       * Activity#onActivityResult} and listening for the {@link
       * BluetoothEventManager#REQUEST_COARSE_LOCATION} request code. */
      if (ContextCompat.checkSelfPermission(context, permission.ACCESS_COARSE_LOCATION)
          != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(
            (Activity) context,
            new String[] {Manifest.permission.ACCESS_COARSE_LOCATION},
            REQUEST_ACCESS_COARSE_LOCATION);
      }
      bluetoothAdapter.startDiscovery();
    }
  }

  /**
   * If a {@link BroadcastReceiver} has been previously registered to receive bluetooth device
   * discovery events via {@link BluetoothEventManager#initiateDiscovery()}, the associated
   * broadcast receiver will be unregistered and bluetooth device discovery will stop.
   */
  public void stopDiscovery() {
    if (isDiscoveryAndPairingReceiverRegistered) {
      context.unregisterReceiver(discoveryAndPairingReceiver);
      isDiscoveryAndPairingReceiverRegistered = false;
    }
  }

  /**
   * If a {@link BroadcastReceiver} has been previously registered to receive bluetooth state change
   * events via {@link BluetoothEventManager#requestEnableBluetoothAndInitiateDiscoveryOnSuccess()},
   * the associated broadcast receiver will be unregistered and bluetooth state change event
   * notifications will stop.
   */
  public void stopListeningForBluetoothStateChange() {
    if (isStateReceiverRegistered) {
      context.unregisterReceiver(stateReceiver);
      isStateReceiverRegistered = false;
    }
  }

  /** Stop listening for all bluetooth events. This will unregister all broadcast receivers. */
  public void stopListeningForAllEvents() {
    stopDiscovery();
    stopListeningForBluetoothStateChange();
  }

  /**
   * Registers an {@link BluetoothCallback} to this event manager. A registered callback will be
   * able to capture bluetooth events defined by {@link BluetoothCallback}.
   *
   * @param callback the {@link BluetoothCallback} to register to this bluetooth event manager
   */
  public void registerCallback(BluetoothCallback callback) {
    bluetoothCallbacks.add(callback);
  }

  /**
   * Starts an intent to enable bluetooth. If successful, discovery will automatically be initiated.
   *
   * <p>Applications are responsible for handling cases when enabling Bluetooth isn't successful by
   * overriding {@link Activity#onActivityResult} and listening for the {@link
   * BluetoothEventManager#REQUEST_ENABLE_BLUETOOTH} request code.
   */
  @RequiresPermission(allOf = {permission.BLUETOOTH, permission.BLUETOOTH_ADMIN})
  public void requestEnableBluetoothAndInitiateDiscoveryOnSuccess() {
    if (!isStateReceiverRegistered) {
      IntentFilter intentFilter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED);
      this.context.registerReceiver(stateReceiver, intentFilter);
      isStateReceiverRegistered = true;
    }

    if (!bluetoothAdapter.isEnabled()) {
      Intent enableBluetoothIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
      ((Activity) context).startActivityForResult(enableBluetoothIntent, REQUEST_ENABLE_BLUETOOTH);
    } else {
      // Restart discovery to make sure we have the most up-to-date information.
      if (bluetoothAdapter.isDiscovering()) {
        bluetoothAdapter.cancelDiscovery();
      }

      updatePairedDevicesAndInitiateDiscovery();
    }
  }

  /**
   * Dispatches device paired events for the already paired bluetooth devices and then initiates the
   * discovery process for nearby bluetooth devices.
   */
  @RequiresPermission(allOf = {permission.BLUETOOTH, permission.BLUETOOTH_ADMIN})
  private void updatePairedDevicesAndInitiateDiscovery() {
    Set<BluetoothDevice> currentlyPairedDevices = bluetoothAdapter.getBondedDevices();

    // TODO: Only show paired devices that are currently available once we can get the
    // connection state of a previously paired device.
    for (BluetoothDevice bluetoothDevice : currentlyPairedDevices) {
      /* The {@link RecyclerView.Adapter} used to display the bluetooth devices is backed by a
       * HashSet, so no duplicate checks are needed in this class. */
      dispatchDevicePairedEvent(
          new ComparableBluetoothDevice(
              context, bluetoothAdapter, bluetoothDevice, bluetoothDeviceActionListener));
    }

    initiateDiscovery();
  }

  private void dispatchDeviceDiscoveredEvent(ComparableBluetoothDevice bluetoothDevice) {
    for (BluetoothCallback callback : bluetoothCallbacks) {
      callback.onDeviceDiscovered(bluetoothDevice);
    }
  }

  private void dispatchDevicePairedEvent(ComparableBluetoothDevice bluetoothDevice) {
    for (BluetoothCallback callback : bluetoothCallbacks) {
      callback.onDevicePaired(bluetoothDevice);
    }
  }

  private void dispatchDeviceConnectionStateChangedEvent(
      ComparableBluetoothDevice bluetoothDevice) {
    for (BluetoothCallback callback : bluetoothCallbacks) {
      callback.onDeviceConnectionStateChanged(bluetoothDevice);
    }
  }

  private void dispatchBluetoothStateChangeEvent(int state) {
    for (BluetoothCallback callback : bluetoothCallbacks) {
      callback.onBluetoothStateChanged(state);
    }
  }

  /** Interface for conveying information related to bluetooth events. */
  public interface BluetoothCallback {

    /**
     * Called when {@link android.bluetooth.BluetoothAdapter#ACTION_STATE_CHANGED} events are
     * received by the {@link BluetoothEventManager}.
     *
     * @param state the current Bluetooth adapter state
     */
    void onBluetoothStateChanged(int state);

    /**
     * Called when {@link android.bluetooth.BluetoothDevice#ACTION_NAME_CHANGED} or {@link
     * android.bluetooth.BluetoothDevice#ACTION_FOUND} events are received by the {@link
     * BluetoothEventManager}.
     *
     * @param bluetoothDevice the Bluetooth device that has recently been discovered
     */
    void onDeviceDiscovered(ComparableBluetoothDevice bluetoothDevice);

    /**
     * Called when {@link android.bluetooth.BluetoothDevice#ACTION_BOND_STATE_CHANGED} events are
     * received by the {@link BluetoothEventManager}.
     *
     * @param bluetoothDevice the bluetooth device that has recently been paired
     */
    void onDevicePaired(ComparableBluetoothDevice bluetoothDevice);

    /**
     * Called when {@link BluetoothDevice#ACTION_ACL_CONNECTED} or {@link
     * BluetoothDevice#ACTION_ACL_DISCONNECTED} events are received by the {@link
     * BluetoothEventManager}.
     *
     * @param bluetoothDevice the Bluetooth device that has recently had a connection state change
     */
    void onDeviceConnectionStateChanged(ComparableBluetoothDevice bluetoothDevice);
  }
}