const debug = require('debug')('gap');

const { EventEmitter } = require('events');
const os = require('os');

const isChip = os.platform() === 'linux' && os.release().indexOf('-ntc') !== -1;

const LE_META_EVENT_TYPE_CONNECTABLE = 0x3;
const LE_META_EVENT_TYPE_SCAN_RESPONSE = 0x4;
const LE_META_EVENT_TYPE_SCANNABLE = 0x6;

const LE_META_EXTENDED_EVENT_TYPE_CONNECTABLE_MASK = 0x1;
const LE_META_EXTENDED_EVENT_TYPE_SCANNABLE_MASK = 0x2;
const LE_META_EXTENDED_EVENT_TYPE_SCAN_RESPONSE_MASK = 0x8;
const LE_META_EXTENDED_EVENT_TYPE_INCOMPLETE_MASK = 0x20;

const Gap = function (hci) {
  this._hci = hci;

  this._scanState = null;
  this._scanFilterDuplicates = null;
  this._discoveries = {};

  this._hci.on('error', this.onHciError.bind(this));
  this._hci.on('leScanParametersSet', this.onHciLeScanParametersSet.bind(this));
  this._hci.on('leScanEnableSet', this.onHciLeScanEnableSet.bind(this));
  this._hci.on('leAdvertisingReport', this.onHciLeAdvertisingReport.bind(this));
  this._hci.on(
    'leExtendedAdvertisingReport',
    this.onHciLeExtendedAdvertisingReport.bind(this)
  );

  this._hci.on('leScanEnableSetCmd', this.onLeScanEnableSetCmd.bind(this));
};

Object.setPrototypeOf(Gap.prototype, EventEmitter.prototype);

Gap.prototype.setScanParameters = function (interval, window) {
  this._hci.setScanParameters(interval, window);
};

Gap.prototype.startScanning = function (allowDuplicates) {
  this._scanState = 'starting';
  this._scanFilterDuplicates = !allowDuplicates;

  // Always set scan parameters before scanning
  // https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=229737
  // p106 - p107
  this._hci.setScanEnabled(false, true);
  this._hci.setScanParameters();

  if (isChip) {
    // work around for Next Thing Co. C.H.I.P, always allow duplicates, to get scan response
    this._scanFilterDuplicates = false;
  }

  this._hci.setScanEnabled(true, this._scanFilterDuplicates);
};

Gap.prototype.stopScanning = function () {
  this._scanState = 'stopping';

  this._hci.setScanEnabled(false, true);
};

Gap.prototype.onHciError = function (error) {
  console.warn(error); // TODO: Better error handling
};

Gap.prototype.onHciLeScanParametersSet = function () {
  this.emit('scanParametersSet');
};

// Called when receive an event "Command Complete" for "LE Set Scan Enable"
Gap.prototype.onHciLeScanEnableSet = function (status) {
  // Check the status we got from the command complete function.
  if (status !== 0) {
    // If it is non-zero there was an error, and we should not change
    // our status as a result.
    return;
  }

  if (this._scanState === 'starting') {
    this._scanState = 'started';

    this.emit('scanStart', this._scanFilterDuplicates);
  } else if (this._scanState === 'stopping') {
    this._scanState = 'stopped';

    this.emit('scanStop');
  }
};

// Called when we see the actual command "LE Set Scan Enable"
Gap.prototype.onLeScanEnableSetCmd = function (enable, filterDuplicates) {
  // Check to see if the new settings differ from what we expect.
  // If we are scanning, then a change happens if the new command stops
  // scanning or if duplicate filtering changes.
  // If we are not scanning, then a change happens if scanning was enabled.
  if (this._scanState === 'starting' || this._scanState === 'started') {
    if (!enable) {
      this.emit('scanStop');
    } else if (this._scanFilterDuplicates !== filterDuplicates) {
      this._scanFilterDuplicates = filterDuplicates;

      this.emit('scanStart', this._scanFilterDuplicates);
    }
  } else if (
    (this._scanState === 'stopping' || this._scanState === 'stopped') &&
    enable
  ) {
    // Someone started scanning on us.
    this.emit('scanStart', this._scanFilterDuplicates);
  }
};

Gap.prototype.onHciLeAdvertisingReport = function (
  status,
  type,
  address,
  addressType,
  eir,
  rssi
) {
  const previouslyDiscovered = !!this._discoveries[address];

  let discoveryCount = previouslyDiscovered
    ? this._discoveries[address].count
    : 0;
  let hasScanResponse = previouslyDiscovered
    ? this._discoveries[address].hasScanResponse
    : false;

  if (type === LE_META_EVENT_TYPE_SCAN_RESPONSE) {
    hasScanResponse = true;
  }

  discoveryCount++;

  const advertisement = this.parseServices(
    address,
    eir,
    previouslyDiscovered,
    type
  );

  if (process.env.DEBUG === 'gap') {
    debug(`advertisement = ${JSON.stringify(advertisement, null, 0)}`);
  }

  const connectable =
    type === LE_META_EVENT_TYPE_SCAN_RESPONSE && previouslyDiscovered
      ? this._discoveries[address].connectable
      : type !== LE_META_EVENT_TYPE_CONNECTABLE;
  const scannable = type === LE_META_EVENT_TYPE_SCANNABLE;

  this._discoveries[address] = {
    address,
    addressType,
    connectable,
    advertisement,
    rssi,
    count: discoveryCount,
    hasScanResponse
  };

  // only report after a scan response event or if non-connectable or more than one discovery without a scan response, so more data can be collected
  if (
    type === LE_META_EVENT_TYPE_SCAN_RESPONSE ||
    !connectable ||
    (discoveryCount > 1 && !hasScanResponse) ||
    process.env.NOBLE_REPORT_ALL_HCI_EVENTS
  ) {
    this.emit(
      'discover',
      status,
      address,
      addressType,
      connectable,
      advertisement,
      rssi,
      scannable
    );
  }
};

Gap.prototype.onHciLeExtendedAdvertisingReport = function (
  status,
  type,
  address,
  addressType,
  txpower,
  rssi,
  eir
) {
  const previouslyDiscovered = !!this._discoveries[address];

  let discoveryCount = previouslyDiscovered
    ? this._discoveries[address].count
    : 0;
  let hasScanResponse = previouslyDiscovered
    ? this._discoveries[address].hasScanResponse
    : false;

  if (type & LE_META_EXTENDED_EVENT_TYPE_SCAN_RESPONSE_MASK) {
    hasScanResponse = true;
  }

  discoveryCount++;

  const advertisement = this.parseServices(
    address,
    eir,
    previouslyDiscovered,
    type,
    txpower
  );

  if (process.env.DEBUG === 'gap') {
    debug(`advertisement = ${JSON.stringify(advertisement, null, 0)}`);
  }

  const connectable =
    type & 0x8 && previouslyDiscovered
      ? this._discoveries[address].connectable
      : type & LE_META_EXTENDED_EVENT_TYPE_CONNECTABLE_MASK;
  const scannable = type & LE_META_EXTENDED_EVENT_TYPE_SCANNABLE_MASK ? 1 : 0;
  const incomplete = type & LE_META_EXTENDED_EVENT_TYPE_INCOMPLETE_MASK ? 1 : 0;

  this._discoveries[address] = {
    address,
    addressType,
    connectable,
    advertisement,
    rssi,
    count: discoveryCount,
    hasScanResponse
  };

  // only report after a scan response event or if non-connectable or more than one discovery without a scan response, so more data can be collected
  if (
    type & LE_META_EXTENDED_EVENT_TYPE_SCAN_RESPONSE_MASK ||
    (!connectable && !incomplete) ||
    (discoveryCount > 1 && !hasScanResponse) ||
    process.env.NOBLE_REPORT_ALL_HCI_EVENTS
  ) {
    this.emit(
      'discover',
      status,
      address,
      addressType,
      connectable,
      advertisement,
      rssi,
      scannable
    );
  }
};

Gap.prototype.parseServices = function (
  address,
  eir,
  previouslyDiscovered,
  leMetaEventType,
  txpower
) {
  let i = 0;
  const advertisement = previouslyDiscovered
    ? this._discoveries[address].advertisement
    : {
        localName: undefined,
        txPowerLevel: txpower,
        manufacturerData: undefined,
        serviceData: [],
        serviceUuids: [],
        serviceSolicitationUuids: []
      };

  while (i + 1 < eir.length) {
    const length = eir.readUInt8(i);

    if (length < 1) {
      debug(`invalid EIR data, length = ${length}`);
      break;
    }

    const eirType = eir.readUInt8(i + 1); // https://www.bluetooth.org/en-us/specification/assigned-numbers/generic-access-profile

    if (i + length + 1 > eir.length) {
      debug('invalid EIR data, out of range of buffer length');
      break;
    }

    const bytes = eir.slice(i + 2).slice(0, length - 1);

    switch (eirType) {
      case 0x02: // Incomplete List of 16-bit Service Class UUID
      case 0x03: // Complete List of 16-bit Service Class UUIDs
        for (let j = 0; j < bytes.length - 1; j += 2) {
          const serviceUuid = bytes.readUInt16LE(j).toString(16);
          if (advertisement.serviceUuids.indexOf(serviceUuid) === -1) {
            advertisement.serviceUuids.push(serviceUuid);
          }
        }
        break;

      case 0x06: // Incomplete List of 128-bit Service Class UUIDs
      case 0x07: // Complete List of 128-bit Service Class UUIDs
        for (let j = 0; j < bytes.length - 15; j += 16) {
          const serviceUuid = bytes
            .slice(j, j + 16)
            .toString('hex')
            .match(/.{1,2}/g)
            .reverse()
            .join('');
          if (advertisement.serviceUuids.indexOf(serviceUuid) === -1) {
            advertisement.serviceUuids.push(serviceUuid);
          }
        }
        break;

      case 0x08: // Shortened Local Name
      case 0x09: // Complete Local Name»
        advertisement.localName = bytes.toString('utf8');
        break;

      case 0x0a: // Tx Power Level
        advertisement.txPowerLevel = bytes.readInt8(0);
        break;

      case 0x14: // List of 16 bit solicitation UUIDs
        for (let j = 0; j < bytes.length - 1; j += 2) {
          const serviceSolicitationUuid = bytes.readUInt16LE(j).toString(16);
          if (
            advertisement.serviceSolicitationUuids.indexOf(
              serviceSolicitationUuid
            ) === -1
          ) {
            advertisement.serviceSolicitationUuids.push(
              serviceSolicitationUuid
            );
          }
        }
        break;

      case 0x15: // List of 128 bit solicitation UUIDs
        for (let j = 0; j < bytes.length - 15; j += 16) {
          const serviceSolicitationUuid = bytes
            .slice(j, j + 16)
            .toString('hex')
            .match(/.{1,2}/g)
            .reverse()
            .join('');
          if (
            advertisement.serviceSolicitationUuids.indexOf(
              serviceSolicitationUuid
            ) === -1
          ) {
            advertisement.serviceSolicitationUuids.push(
              serviceSolicitationUuid
            );
          }
        }
        break;

      case 0x16: // 16-bit Service Data
      case 0x20: // 32-bit Service Data
      case 0x21: // 128-bit Service Data
        {
          let uuidLength;
          if (eirType === 0x16) {
            uuidLength = 2;
          } else if (eirType === 0x20) {
            uuidLength = 4;
          } else {
            uuidLength = 16;
          }

          if (bytes.length < uuidLength) {
            debug(
              `invalid EIR service data, type = 0x${eirType.toString(16)}, expected uuid bytes = ${uuidLength}, actual = ${bytes.length}`
            );
            break;
          }

          const serviceUuid = bytes
            .slice(0, uuidLength)
            .toString('hex')
            .match(/.{1,2}/g)
            .reverse()
            .join('');

          // Find existing service data index
          const existingIndex = advertisement.serviceData.findIndex(
            s => s.uuid === serviceUuid
          );
          const serviceData = {
            uuid: serviceUuid,
            data: bytes.slice(uuidLength, bytes.length)
          };

          // Update or append service data
          if (existingIndex >= 0) {
            advertisement.serviceData[existingIndex] = serviceData;
          } else {
            advertisement.serviceData.push(serviceData);
          }
        }
        break;

      case 0x1f: // List of 32 bit solicitation UUIDs
        for (let j = 0; j < bytes.length - 3; j += 4) {
          const serviceSolicitationUuid = bytes.readUInt32LE(j).toString(16);
          if (
            advertisement.serviceSolicitationUuids.indexOf(
              serviceSolicitationUuid
            ) === -1
          ) {
            advertisement.serviceSolicitationUuids.push(
              serviceSolicitationUuid
            );
          }
        }
        break;

      case 0xff: // Manufacturer Specific Data
        advertisement.manufacturerData = bytes;
        break;
    }

    i += length + 1;
  }

  return advertisement;
};

module.exports = Gap;
