/**
 * Bluetooth Terminal class.
 */
class BluetoothTerminal {
  /**
   * Create preconfigured Bluetooth Terminal instance.
   *
   * @param {!(number|string)} [serviceUuid=0xFFE0] - Service UUID.
   * @param {!(number|string)} [characteristicUuid=0xFFE1] - Characteristic UUID.
   * @param {string} [receiveSeparator='\n'] - Receive separator.
   * @param {string} [sendSeparator='\n'] - Send separator.
   * @param {Function|undefined} [onConnected=undefined] - Listener for connected event.
   * @param {Function|undefined} [onDisconnected=undefined] - Listener for disconnected event.
   */
  constructor(serviceUuid = 0xFFE0, characteristicUuid = 0xFFE1, receiveSeparator = '\n', sendSeparator = '\n',
      onConnected = undefined, onDisconnected = undefined) {
    // Used private variables.
    this._receiveBuffer = ''; // Buffer containing not separated data.
    this._maxCharacteristicValueLength = 20; // Max characteristic value length.
    this._device = null; // Device object cache.
    this._characteristic = null; // Characteristic object cache.

    // Bound functions used to add and remove appropriate event handlers.
    this._boundHandleDisconnection = this._handleDisconnection.bind(this);
    this._boundHandleCharacteristicValueChanged = this._handleCharacteristicValueChanged.bind(this);

    // Configure with specified parameters.
    this.setServiceUuid(serviceUuid);
    this.setCharacteristicUuid(characteristicUuid);
    this.setReceiveSeparator(receiveSeparator);
    this.setSendSeparator(sendSeparator);
    this.setOnConnected(onConnected);
    this.setOnDisconnected(onDisconnected);
  }

  /**
   * Set number or string representing service UUID used.
   *
   * @param {!(number|string)} uuid - Service UUID.
   */
  setServiceUuid(uuid) {
    if (!Number.isInteger(uuid) && !(typeof uuid === 'string' || uuid instanceof String)) {
      throw new Error('UUID type is neither a number nor a string');
    }

    if (!uuid) {
      throw new Error('UUID cannot be a null');
    }

    this._serviceUuid = uuid;
  }

  /**
   * Set number or string representing characteristic UUID used.
   *
   * @param {!(number|string)} uuid - Characteristic UUID.
   */
  setCharacteristicUuid(uuid) {
    if (!Number.isInteger(uuid) && !(typeof uuid === 'string' || uuid instanceof String)) {
      throw new Error('UUID type is neither a number nor a string');
    }

    if (!uuid) {
      throw new Error('UUID cannot be a null');
    }

    this._characteristicUuid = uuid;
  }

  /**
   * Set character representing separator for data coming from the connected device, end of line for example.
   *
   * @param {string} separator - Receive separator with length equal to one character.
   */
  setReceiveSeparator(separator) {
    if (!(typeof separator === 'string' || separator instanceof String)) {
      throw new Error('Separator type is not a string');
    }

    if (separator.length !== 1) {
      throw new Error('Separator length must be equal to one character');
    }

    this._receiveSeparator = separator;
  }

  /**
   * Set string representing separator for data coming to the connected device, end of line for example.
   *
   * @param {string} separator - Send separator.
   */
  setSendSeparator(separator) {
    if (!(typeof separator === 'string' || separator instanceof String)) {
      throw new Error('Separator type is not a string');
    }

    if (separator.length !== 1) {
      throw new Error('Separator length must be equal to one character');
    }

    this._sendSeparator = separator;
  }

  /**
   * Set a listener to be called after a device is connected.
   *
   * @param {Function|undefined} listener - Listener for connected event.
   */
  setOnConnected(listener) {
    this._onConnected = listener;
  }

  /**
   * Set a listener to be called after a device is disconnected.
   *
   * @param {Function|undefined} listener - Listener for disconnected event.
   */
  setOnDisconnected(listener) {
    this._onDisconnected = listener;
  }

  /**
   * Launch Bluetooth device chooser and connect to the selected device.
   *
   * @returns {Promise} Promise which will be fulfilled when notifications will be started or rejected if something went
   * wrong.
   */
  connect() {
    return this._connectToDevice(this._device).
        then(() => {
          if (this._onConnected) {
            this._onConnected();
          }
        });
  }

  /**
   * Disconnect from the connected device.
   */
  disconnect() {
    this._disconnectFromDevice(this._device);

    if (this._characteristic) {
      this._characteristic.removeEventListener('characteristicvaluechanged',
          this._boundHandleCharacteristicValueChanged);
      this._characteristic = null;
    }

    this._device = null;

    if (this._onDisconnected) {
      this._onDisconnected();
    }
  }

  /**
   * Data receiving handler which called whenever the new data comes from the connected device, override it to handle
   * incoming data.
   *
   * @param {string} data - Data.
   */
  receive(data) {
    // Handle incoming data.
  }

  /**
   * Send data to the connected device.
   *
   * @param {string} data - Data.
   * @returns {Promise} Promise which will be fulfilled when data will be sent or rejected if something went wrong.
   */
  send(data) {
    // Convert data to the string using global object.
    data = String(data || '');

    // Return rejected promise immediately if data is empty.
    if (!data) {
      return Promise.reject(new Error('Data must be not empty'));
    }

    data += this._sendSeparator;

    // Split data to chunks by max characteristic value length.
    const chunks = this.constructor._splitByLength(data, this._maxCharacteristicValueLength);

    // Return rejected promise immediately if there is no connected device.
    if (!this._characteristic) {
      return Promise.reject(new Error('There is no connected device'));
    }

    // Write first chunk to the characteristic immediately.
    let promise = this._writeToCharacteristic(this._characteristic, chunks[0]);

    // Iterate over chunks if there are more than one of it.
    for (let i = 1; i < chunks.length; i++) {
      // Chain new promise.
      promise = promise.then(() => new Promise((resolve, reject) => {
        // Reject promise if the device has been disconnected.
        if (!this._characteristic) {
          reject(new Error('Device has been disconnected'));
        }

        // Write chunk to the characteristic and resolve the promise.
        this._writeToCharacteristic(this._characteristic, chunks[i]).
            then(resolve).
            catch(reject);
      }));
    }

    return promise;
  }

  /**
   * Get the connected device name.
   *
   * @returns {string} Device name or empty string if not connected.
   */
  getDeviceName() {
    if (!this._device) {
      return '';
    }

    return this._device.name;
  }

  /**
   * Connect to device.
   *
   * @param {object} device - Device.
   * @returns {Promise} Promise.
   * @private
   */
  _connectToDevice(device) {
    return (device ? Promise.resolve(device) : this._requestBluetoothDevice()).
        then((device) => this._connectDeviceAndCacheCharacteristic(device)).
        then((characteristic) => this._startNotifications(characteristic)).
        catch((error) => {
          this._log(error);
          return Promise.reject(error);
        });
  }

  /**
   * Disconnect from device.
   *
   * @param {object} device - Device.
   * @returns {undefined} Undefined.
   * @private
   */
  _disconnectFromDevice(device) {
    if (!device) {
      return;
    }

    this._log('Disconnecting from "' + device.name + '" bluetooth device...');

    device.removeEventListener('gattserverdisconnected', this._boundHandleDisconnection);

    if (!device.gatt.connected) {
      this._log('"' + device.name + '" bluetooth device is already disconnected');
      return;
    }

    device.gatt.disconnect();

    this._log('"' + device.name + '" bluetooth device disconnected');
  }

  /**
   * Request bluetooth device.
   *
   * @returns {Promise} Promise.
   * @private
   */
  _requestBluetoothDevice() {
    this._log('Requesting bluetooth device...');

    return navigator.bluetooth.requestDevice({
      filters: [{services: [this._serviceUuid]}],
    }).
        then((device) => {
          this._log('"' + device.name + '" bluetooth device selected');

          this._device = device; // Remember device.
          this._device.addEventListener('gattserverdisconnected', this._boundHandleDisconnection);

          return this._device;
        });
  }

  /**
   * Connect device and cache characteristic.
   *
   * @param {object} device - Device.
   * @returns {Promise} Promise.
   * @private
   */
  _connectDeviceAndCacheCharacteristic(device) {
    // Check remembered characteristic.
    if (device.gatt.connected && this._characteristic) {
      return Promise.resolve(this._characteristic);
    }

    this._log('Connecting to GATT server...');

    return device.gatt.connect().
        then((server) => {
          this._log('GATT server connected', 'Getting service...');

          return server.getPrimaryService(this._serviceUuid);
        }).
        then((service) => {
          this._log('Service found', 'Getting characteristic...');

          return service.getCharacteristic(this._characteristicUuid);
        }).
        then((characteristic) => {
          this._log('Characteristic found');

          this._characteristic = characteristic; // Remember characteristic.

          return this._characteristic;
        });
  }

  /**
   * Start notifications.
   *
   * @param {object} characteristic - Characteristic.
   * @returns {Promise} Promise.
   * @private
   */
  _startNotifications(characteristic) {
    this._log('Starting notifications...');

    return characteristic.startNotifications().
        then(() => {
          this._log('Notifications started');

          characteristic.addEventListener('characteristicvaluechanged', this._boundHandleCharacteristicValueChanged);
        });
  }

  /**
   * Stop notifications.
   *
   * @param {object} characteristic - Characteristic.
   * @returns {Promise} Promise.
   * @private
   */
  _stopNotifications(characteristic) {
    this._log('Stopping notifications...');

    return characteristic.stopNotifications().
        then(() => {
          this._log('Notifications stopped');

          characteristic.removeEventListener('characteristicvaluechanged', this._boundHandleCharacteristicValueChanged);
        });
  }

  /**
   * Handle disconnection.
   *
   * @param {object} event - Event.
   * @returns {undefined} Undefined.
   * @private
   */
  _handleDisconnection(event) {
    const device = event.target;

    this._log('"' + device.name + '" bluetooth device disconnected, trying to reconnect...');

    if (this._onDisconnected) {
      this._onDisconnected();
    }

    this._connectDeviceAndCacheCharacteristic(device).
        then((characteristic) => this._startNotifications(characteristic)).
        then(() => {
          if (this._onConnected) {
            this._onConnected();
          }
        }).
        catch((error) => this._log(error));
  }

  /**
   * Handle characteristic value changed.
   *
   * @param {object} event - Event.
   * @private
   */
  _handleCharacteristicValueChanged(event) {
    const value = new TextDecoder().decode(event.target.value);

    for (const c of value) {
      if (c === this._receiveSeparator) {
        const data = this._receiveBuffer.trim();
        this._receiveBuffer = '';

        if (data) {
          this.receive(data);
        }
      } else {
        this._receiveBuffer += c;
      }
    }
  }

  /**
   * Write to characteristic.
   *
   * @param {object} characteristic - Characteristic.
   * @param {string} data - Data.
   * @returns {Promise} Promise.
   * @private
   */
  _writeToCharacteristic(characteristic, data) {
    return characteristic.writeValue(new TextEncoder().encode(data));
  }

  /**
   * Log.
   *
   * @param {Array} messages - Messages.
   * @private
   */
  _log(...messages) {
    console.log(...messages); // eslint-disable-line no-console
  }

  /**
   * Split by length.
   *
   * @param {string} string - String.
   * @param {number} length - Length.
   * @returns {Array} Array.
   * @private
   */
  static _splitByLength(string, length) {
    return string.match(new RegExp('(.|[\r\n]){1,' + length + '}', 'g'));
  }
}

// Export class as a module to support requiring.
/* istanbul ignore next */
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
  module.exports = BluetoothTerminal;
}