const { EventEmitter } = require('events');
const debug = require('debug')('noble-dbus');

const {
  normalizeUuid,
  expandUuid,
  addressToId,
  devicePathToAddress,
  deviceIdFromPath,
  devicePathFromAddress
} = require('./uuid');

const BLUEZ_SERVICE = 'org.bluez';
const ROOT_PATH = '/';
const ADAPTER_IFACE = 'org.bluez.Adapter1';
const DEVICE_IFACE = 'org.bluez.Device1';
const GATT_SERVICE_IFACE = 'org.bluez.GattService1';
const GATT_CHAR_IFACE = 'org.bluez.GattCharacteristic1';
const GATT_DESC_IFACE = 'org.bluez.GattDescriptor1';
const PROPS_IFACE = 'org.freedesktop.DBus.Properties';
const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager';

const FLAG_TO_PROPERTY = {
  broadcast: 'broadcast',
  read: 'read',
  'write-without-response': 'writeWithoutResponse',
  write: 'write',
  notify: 'notify',
  indicate: 'indicate',
  'authenticated-signed-writes': 'authenticatedSignedWrites',
  'reliable-write': 'extendedProperties',
  'writable-auxiliaries': 'extendedProperties'
};

function loadDbus () {
  try {
    // dbus-next is an optional peer of this Linux-only backend; the host
    // project installs it explicitly. eslint-plugin-node would otherwise
    // flag the missing module on platforms where it is not present.
    // eslint-disable-next-line node/no-missing-require
    return require('dbus-next');
  } catch (err) {
    const wrapped = new Error(
      'noble dbus backend requires the "dbus-next" package. ' +
      'Install it with: npm install dbus-next'
    );
    wrapped.cause = err;
    throw wrapped;
  }
}

function normalizeId (id) {
  // BlueZ emits MACs uppercase; noble's id form is colon-stripped lowercase.
  // Accept either, plus mixed case, so external callers don't have to care.
  if (id == null) return id;
  return String(id).replace(/:/g, '').toLowerCase();
}

function unwrapVariant (variant) {
  if (variant && typeof variant === 'object' && 'value' in variant && 'signature' in variant) {
    return variant.value;
  }
  return variant;
}

function unwrapDict (dict) {
  const out = {};
  if (!dict) return out;
  for (const key of Object.keys(dict)) {
    out[key] = unwrapVariant(dict[key]);
  }
  return out;
}

function flagsToProperties (flags) {
  const set = new Set();
  for (const flag of flags || []) {
    const mapped = FLAG_TO_PROPERTY[flag];
    if (mapped) set.add(mapped);
  }
  return Array.from(set);
}

function buildAdvertisement (deviceProps) {
  const advertisement = {
    localName: undefined,
    txPowerLevel: undefined,
    manufacturerData: undefined,
    serviceData: [],
    serviceUuids: [],
    serviceSolicitationUuids: []
  };

  if (typeof deviceProps.Name === 'string') {
    advertisement.localName = deviceProps.Name;
  } else if (typeof deviceProps.Alias === 'string') {
    advertisement.localName = deviceProps.Alias;
  }

  if (typeof deviceProps.TxPower === 'number') {
    advertisement.txPowerLevel = deviceProps.TxPower;
  }

  if (Array.isArray(deviceProps.UUIDs)) {
    advertisement.serviceUuids = deviceProps.UUIDs.map(normalizeUuid);
  }

  if (deviceProps.ManufacturerData && typeof deviceProps.ManufacturerData === 'object') {
    const entries = Object.entries(deviceProps.ManufacturerData);
    if (entries.length > 0) {
      const buffers = [];
      for (const [companyId, rawPayload] of entries) {
        const payload = unwrapVariant(rawPayload);
        const id = Number(companyId) & 0xffff;
        const header = Buffer.from([id & 0xff, (id >> 8) & 0xff]);
        const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
        buffers.push(Buffer.concat([header, data]));
      }
      advertisement.manufacturerData = buffers.length === 1 ? buffers[0] : Buffer.concat(buffers);
    }
  }

  if (deviceProps.ServiceData && typeof deviceProps.ServiceData === 'object') {
    for (const [uuid, rawPayload] of Object.entries(deviceProps.ServiceData)) {
      const payload = unwrapVariant(rawPayload);
      advertisement.serviceData.push({
        uuid: normalizeUuid(uuid),
        data: Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
      });
    }
  }

  return advertisement;
}

class DbusBindings extends EventEmitter {
  constructor (options = {}) {
    super();
    this._options = options || {};
    this._dbus = null;
    this._bus = null;
    this._rootProxy = null;
    this._objectManager = null;

    this._adapterPath = null;
    this._adapterProxy = null;
    this._adapterIface = null;
    this._adapterProps = null;
    this._adapterAddress = 'unknown';
    this._state = 'unknown';
    this._isScanning = false;

    // Object tree mirror: path -> { iface: props }
    this._objects = new Map();

    // Discovery filter for service uuid filtering
    this._scanServiceUuids = [];

    // Per-device live state
    // id -> { path, address, addressType, connectable, scannable, rssi, advertisement, proxy, propsListener, connectPromise, servicesResolved }
    this._devices = new Map();

    // Per-characteristic notify listener: path -> { iface, listener }
    this._charPropsListeners = new Map();

    this._onInterfacesAddedBound = this._onInterfacesAdded.bind(this);
    this._onInterfacesRemovedBound = this._onInterfacesRemoved.bind(this);
    this._onAdapterPropsBound = this._onAdapterProperties.bind(this);
  }

  start () {
    this._dbus = loadDbus();
    this._bus = this._dbus.systemBus();
    this._init().catch(err => {
      debug('init failed: %s', err && err.stack);
      this.emit('warning', `dbus init failed: ${err.message}`);
      this._state = 'unsupported';
      this.emit('stateChange', 'unsupported');
    });
  }

  async _init () {
    this._rootProxy = await this._bus.getProxyObject(BLUEZ_SERVICE, ROOT_PATH);
    this._objectManager = this._rootProxy.getInterface(OBJECT_MANAGER_IFACE);
    this._objectManager.on('InterfacesAdded', this._onInterfacesAddedBound);
    this._objectManager.on('InterfacesRemoved', this._onInterfacesRemovedBound);

    const managed = await this._objectManager.GetManagedObjects();
    for (const [path, ifaces] of Object.entries(managed)) {
      const unwrapped = {};
      for (const [iface, props] of Object.entries(ifaces)) {
        unwrapped[iface] = unwrapDict(props);
      }
      this._objects.set(path, unwrapped);
    }

    const adapterPath = this._pickAdapterPath();
    if (!adapterPath) {
      throw new Error('No BlueZ adapter found (is bluetoothd running?)');
    }
    this._adapterPath = adapterPath;

    const adapterProxy = await this._bus.getProxyObject(BLUEZ_SERVICE, adapterPath);
    this._adapterProxy = adapterProxy;
    this._adapterIface = adapterProxy.getInterface(ADAPTER_IFACE);
    this._adapterProps = adapterProxy.getInterface(PROPS_IFACE);
    this._adapterProps.on('PropertiesChanged', this._onAdapterPropsBound);

    const adapterProps = this._objects.get(adapterPath)[ADAPTER_IFACE] || {};
    if (typeof adapterProps.Address === 'string') {
      this._adapterAddress = adapterProps.Address;
      this.emit('addressChange', adapterProps.Address);
    }

    const powered = !!adapterProps.Powered;
    this._setState(powered ? 'poweredOn' : 'poweredOff');

    // Surface devices that already exist in BlueZ's cache as discovery events.
    for (const [path, ifaces] of this._objects) {
      if (ifaces[DEVICE_IFACE] && this._isUnderAdapter(path)) {
        this._handleDeviceProps(path, ifaces[DEVICE_IFACE]);
      }
    }
  }

  _pickAdapterPath () {
    const requested = this._options.adapterId
      || (this._options.hciDeviceId != null ? `hci${this._options.hciDeviceId}` : null);
    let firstMatch = null;
    for (const [path, ifaces] of this._objects) {
      if (!ifaces[ADAPTER_IFACE]) continue;
      if (!firstMatch) firstMatch = path;
      if (requested && path.endsWith(`/${requested}`)) return path;
    }
    return firstMatch;
  }

  _isUnderAdapter (path) {
    return this._adapterPath && path.startsWith(`${this._adapterPath}/`);
  }

  _setState (state) {
    if (this._state === state) return;
    this._state = state;
    this.emit('stateChange', state);
  }

  stop () {
    if (this._objectManager) {
      this._objectManager.off('InterfacesAdded', this._onInterfacesAddedBound);
      this._objectManager.off('InterfacesRemoved', this._onInterfacesRemovedBound);
    }
    if (this._adapterProps) {
      this._adapterProps.off('PropertiesChanged', this._onAdapterPropsBound);
    }
    for (const device of this._devices.values()) {
      if (device.proxy && device.propsListener) {
        const props = device.proxy.getInterface(PROPS_IFACE);
        props.off('PropertiesChanged', device.propsListener);
      }
    }
    for (const entry of this._charPropsListeners.values()) {
      entry.props.off('PropertiesChanged', entry.listener);
    }
    this._charPropsListeners.clear();
    if (this._isScanning && this._adapterIface) {
      this._adapterIface.StopDiscovery().catch(() => {});
    }
    if (this._bus && typeof this._bus.disconnect === 'function') {
      try { this._bus.disconnect(); } catch (_) { /* ignore */ }
    }
  }

  setScanParameters (_interval, _window) {
    this.emit('warning', 'setScanParameters is not supported on the dbus backend (BlueZ controls scan parameters)');
    this.emit('scanParametersSet');
  }

  setAddress (_address) {
    this.emit('warning', 'setAddress is not supported on the dbus backend');
  }

  startScanning (serviceUuids, allowDuplicates) {
    this._scanServiceUuids = (serviceUuids || []).map(normalizeUuid);
    this._startScanning(allowDuplicates).catch(err => {
      this.emit('warning', `startScanning failed: ${err.message}`);
    });
  }

  async _startScanning (allowDuplicates) {
    if (!this._adapterIface) {
      throw new Error('adapter not initialized');
    }
    const { Variant } = this._dbus;
    const filter = {
      Transport: new Variant('s', 'le'),
      DuplicateData: new Variant('b', !!allowDuplicates)
    };
    if (this._scanServiceUuids.length > 0) {
      filter.UUIDs = new Variant('as', this._scanServiceUuids.map(expandUuid));
    }
    try {
      await this._adapterIface.SetDiscoveryFilter(filter);
    } catch (err) {
      debug('SetDiscoveryFilter failed: %s', err.message);
    }
    if (!this._isScanning) {
      await this._adapterIface.StartDiscovery();
      this._isScanning = true;
    }
    this.emit('scanStart', !!allowDuplicates);
  }

  stopScanning () {
    this._stopScanning().catch(err => {
      this.emit('warning', `stopScanning failed: ${err.message}`);
      this.emit('scanStop');
    });
  }

  async _stopScanning () {
    if (this._isScanning && this._adapterIface) {
      try {
        await this._adapterIface.StopDiscovery();
      } catch (err) {
        debug('StopDiscovery failed: %s', err.message);
      }
      this._isScanning = false;
    }
    this.emit('scanStop');
  }

  // ---- ObjectManager + property change handlers ----

  _onInterfacesAdded (path, ifaces) {
    const unwrapped = {};
    for (const [iface, props] of Object.entries(ifaces)) {
      unwrapped[iface] = unwrapDict(props);
    }
    const existing = this._objects.get(path) || {};
    this._objects.set(path, Object.assign(existing, unwrapped));

    if (unwrapped[DEVICE_IFACE] && this._isUnderAdapter(path)) {
      this._handleDeviceProps(path, unwrapped[DEVICE_IFACE]);
    }
    // Trigger services-resolved processing if a device just gained ServicesResolved
    if (unwrapped[GATT_SERVICE_IFACE] || unwrapped[GATT_CHAR_IFACE] || unwrapped[GATT_DESC_IFACE]) {
      // No direct emit; clients call discoverServices/Characteristics/Descriptors.
    }
  }

  _onInterfacesRemoved (path, ifaces) {
    const stored = this._objects.get(path);
    if (stored) {
      for (const iface of ifaces) delete stored[iface];
      if (Object.keys(stored).length === 0) {
        this._objects.delete(path);
      }
    }
    if (ifaces.includes(DEVICE_IFACE)) {
      const id = deviceIdFromPath(path);
      if (id && this._devices.has(id)) {
        this._onDeviceDisconnected(id, 'removed');
      }
    }
  }

  _onAdapterProperties (iface, changed) {
    if (iface !== ADAPTER_IFACE) return;
    const props = unwrapDict(changed);
    if ('Powered' in props) {
      this._setState(props.Powered ? 'poweredOn' : 'poweredOff');
    }
    if (typeof props.Address === 'string') {
      this._adapterAddress = props.Address;
      this.emit('addressChange', props.Address);
    }
    const stored = this._objects.get(this._adapterPath) || {};
    stored[ADAPTER_IFACE] = Object.assign(stored[ADAPTER_IFACE] || {}, props);
    this._objects.set(this._adapterPath, stored);
  }

  _handleDeviceProps (path, props) {
    const address = props.Address || devicePathToAddress(path);
    if (!address) return;
    const id = addressToId(address);
    let device = this._devices.get(id);
    if (!device) {
      device = {
        path,
        address,
        addressType: props.AddressType || 'unknown',
        connectable: true,
        scannable: false,
        rssi: typeof props.RSSI === 'number' ? props.RSSI : 0,
        advertisement: buildAdvertisement(props),
        proxy: null,
        propsListener: null,
        servicesResolved: !!props.ServicesResolved,
        connectPromise: null
      };
      this._devices.set(id, device);
    } else {
      device.path = path;
      device.address = address;
      if (props.AddressType) device.addressType = props.AddressType;
      if (typeof props.RSSI === 'number') device.rssi = props.RSSI;
      Object.assign(device.advertisement, buildAdvertisement(props));
    }

    this.emit(
      'discover',
      id,
      device.address,
      device.addressType,
      device.connectable,
      device.advertisement,
      device.rssi,
      device.scannable
    );
  }

  // ---- Per-device proxy + property listening ----

  async _ensureDeviceProxy (id) {
    const device = this._devices.get(id);
    if (!device) throw new Error(`unknown peripheral ${id}`);
    if (device.proxy) return device;
    const path = device.path || devicePathFromAddress(this._adapterPath, device.address);
    device.path = path;
    device.proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
    const props = device.proxy.getInterface(PROPS_IFACE);
    device.propsListener = (iface, changed) => {
      if (iface !== DEVICE_IFACE) return;
      const c = unwrapDict(changed);
      if ('RSSI' in c) {
        device.rssi = c.RSSI;
        this.emit('rssiUpdate', id, c.RSSI);
      }
      if ('Connected' in c) {
        if (c.Connected) {
          // Wait for ServicesResolved to fire 'connect' with services available.
          // If ServicesResolved already true (cached device), fire now.
          if (device.servicesResolved && device.connectPromise) {
            device.connectPromise.resolve();
            device.connectPromise = null;
          }
        } else {
          this._onDeviceDisconnected(id, 'remote');
        }
      }
      if ('ServicesResolved' in c) {
        device.servicesResolved = !!c.ServicesResolved;
        if (c.ServicesResolved && device.connectPromise) {
          device.connectPromise.resolve();
          device.connectPromise = null;
        }
      }
    };
    props.on('PropertiesChanged', device.propsListener);
    return device;
  }

  _onDeviceDisconnected (id, reason) {
    const device = this._devices.get(id);
    if (!device) return;
    if (device.proxy && device.propsListener) {
      const props = device.proxy.getInterface(PROPS_IFACE);
      props.off('PropertiesChanged', device.propsListener);
    }
    device.proxy = null;
    device.propsListener = null;
    device.servicesResolved = false;
    if (device.connectPromise) {
      device.connectPromise.reject(new Error(`disconnected: ${reason}`));
      device.connectPromise = null;
    }
    this._removeDeviceCharListeners(id);
    this.emit('disconnect', id, reason);
  }

  _removeDeviceCharListeners (id) {
    const device = this._devices.get(id);
    if (!device || !device.path) return;
    const prefix = `${device.path}/`;
    for (const [path, entry] of this._charPropsListeners) {
      if (path.startsWith(prefix)) {
        entry.props.off('PropertiesChanged', entry.listener);
        this._charPropsListeners.delete(path);
      }
    }
  }

  // ---- Connect / disconnect ----

  connect (peripheralUuid, _parameters) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._connect(peripheralUuid).catch(err => {
      this.emit('connect', peripheralUuid, err);
    });
  }

  async _connect (id) {
    const device = await this._ensureDeviceProxy(id);
    const iface = device.proxy.getInterface(DEVICE_IFACE);

    const cached = this._objects.get(device.path) || {};
    const deviceProps = cached[DEVICE_IFACE] || {};
    if (deviceProps.Connected && deviceProps.ServicesResolved) {
      device.servicesResolved = true;
      this.emit('connect', id, null);
      return;
    }

    const waitConnected = new Promise((resolve, reject) => {
      device.connectPromise = { resolve, reject };
    });

    try {
      await iface.Connect();
    } catch (err) {
      device.connectPromise = null;
      throw err;
    }

    await waitConnected;
    this.emit('connect', id, null);
  }

  cancelConnect (peripheralUuid, _parameters) {
    this.disconnect(normalizeId(peripheralUuid));
  }

  disconnect (peripheralUuid) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._disconnect(peripheralUuid).catch(err => {
      this.emit('warning', `disconnect failed: ${err.message}`);
    });
  }

  async _disconnect (id) {
    const device = this._devices.get(id);
    if (!device) return;
    if (!device.proxy) return;
    const iface = device.proxy.getInterface(DEVICE_IFACE);
    try {
      await iface.Disconnect();
    } catch (err) {
      debug('Disconnect call failed: %s', err.message);
    }
  }

  updateRssi (peripheralUuid) {
    peripheralUuid = normalizeId(peripheralUuid);
    const device = this._devices.get(peripheralUuid);
    if (!device || !device.path) {
      this.emit('rssiUpdate', peripheralUuid, 0, new Error('unknown peripheral'));
      return;
    }
    const cached = this._objects.get(device.path) || {};
    const props = cached[DEVICE_IFACE] || {};
    const rssi = typeof props.RSSI === 'number' ? props.RSSI : (device.rssi || 0);
    this.emit('rssiUpdate', peripheralUuid, rssi);
  }

  // ---- Service / characteristic / descriptor discovery ----

  _findServicesForDevice (id) {
    const device = this._devices.get(id);
    if (!device || !device.path) return [];
    const prefix = `${device.path}/`;
    const services = [];
    for (const [path, ifaces] of this._objects) {
      if (!path.startsWith(prefix)) continue;
      const svc = ifaces[GATT_SERVICE_IFACE];
      if (!svc) continue;
      services.push({ path, uuid: normalizeUuid(svc.UUID), primary: !!svc.Primary });
    }
    return services;
  }

  _findCharacteristicsForService (servicePath) {
    const prefix = `${servicePath}/`;
    const result = [];
    for (const [path, ifaces] of this._objects) {
      if (!path.startsWith(prefix)) continue;
      const ch = ifaces[GATT_CHAR_IFACE];
      if (!ch) continue;
      result.push({
        path,
        uuid: normalizeUuid(ch.UUID),
        properties: flagsToProperties(ch.Flags)
      });
    }
    return result;
  }

  _findDescriptorsForCharacteristic (charPath) {
    const prefix = `${charPath}/`;
    const result = [];
    for (const [path, ifaces] of this._objects) {
      if (!path.startsWith(prefix)) continue;
      const d = ifaces[GATT_DESC_IFACE];
      if (!d) continue;
      result.push({ path, uuid: normalizeUuid(d.UUID) });
    }
    return result;
  }

  discoverServices (peripheralUuid, uuids) {
    peripheralUuid = normalizeId(peripheralUuid);
    const wanted = (uuids || []).map(normalizeUuid);
    const found = this._findServicesForDevice(peripheralUuid);
    const filtered = wanted.length === 0 ? found : found.filter(s => wanted.includes(s.uuid));
    const serviceUuids = filtered.map(s => s.uuid);
    this.emit('servicesDiscover', peripheralUuid, serviceUuids);
    this.emit('servicesDiscovered', peripheralUuid, serviceUuids);
  }

  discoverIncludedServices (peripheralUuid, serviceUuid, _serviceUuids) {
    peripheralUuid = normalizeId(peripheralUuid);
    // BlueZ does not expose included services directly via D-Bus.
    this.emit('includedServicesDiscover', peripheralUuid, serviceUuid, []);
  }

  discoverCharacteristics (peripheralUuid, serviceUuid, characteristicUuids) {
    peripheralUuid = normalizeId(peripheralUuid);
    const services = this._findServicesForDevice(peripheralUuid);
    const service = services.find(s => s.uuid === normalizeUuid(serviceUuid));
    if (!service) {
      this.emit('characteristicsDiscover', peripheralUuid, serviceUuid, [], new Error('service not found'));
      return;
    }
    const wanted = (characteristicUuids || []).map(normalizeUuid);
    const all = this._findCharacteristicsForService(service.path);
    const filtered = wanted.length === 0 ? all : all.filter(c => wanted.includes(c.uuid));
    const result = filtered.map(c => ({ uuid: c.uuid, properties: c.properties }));
    this.emit('characteristicsDiscover', peripheralUuid, serviceUuid, result);
    this.emit('characteristicsDiscovered', peripheralUuid, serviceUuid, result);
  }

  _findCharacteristicPath (peripheralUuid, serviceUuid, characteristicUuid) {
    const services = this._findServicesForDevice(peripheralUuid);
    const service = services.find(s => s.uuid === normalizeUuid(serviceUuid));
    if (!service) return null;
    const chars = this._findCharacteristicsForService(service.path);
    const ch = chars.find(c => c.uuid === normalizeUuid(characteristicUuid));
    return ch ? ch.path : null;
  }

  _findDescriptorPath (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) {
    const charPath = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
    if (!charPath) return null;
    const descs = this._findDescriptorsForCharacteristic(charPath);
    const d = descs.find(x => x.uuid === normalizeUuid(descriptorUuid));
    return d ? d.path : null;
  }

  read (peripheralUuid, serviceUuid, characteristicUuid) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._readChar(peripheralUuid, serviceUuid, characteristicUuid).catch(err => {
      this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, null, false, err);
    });
  }

  async _readChar (peripheralUuid, serviceUuid, characteristicUuid) {
    const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
    if (!path) throw new Error('characteristic not found');
    const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
    const iface = proxy.getInterface(GATT_CHAR_IFACE);
    const value = await iface.ReadValue({});
    const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
    this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, buf, false);
  }

  write (peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._writeChar(peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse).catch(err => {
      this.emit('write', peripheralUuid, serviceUuid, characteristicUuid, err);
    });
  }

  async _writeChar (peripheralUuid, serviceUuid, characteristicUuid, data, withoutResponse) {
    const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
    if (!path) throw new Error('characteristic not found');
    const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
    const iface = proxy.getInterface(GATT_CHAR_IFACE);
    const { Variant } = this._dbus;
    const options = { type: new Variant('s', withoutResponse ? 'command' : 'request') };
    const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
    await iface.WriteValue(buf, options);
    this.emit('write', peripheralUuid, serviceUuid, characteristicUuid);
  }

  broadcast (peripheralUuid, serviceUuid, characteristicUuid, _broadcast) {
    peripheralUuid = normalizeId(peripheralUuid);
    this.emit('warning', 'broadcast is not supported on the dbus backend');
    this.emit('broadcast', peripheralUuid, serviceUuid, characteristicUuid, false);
  }

  notify (peripheralUuid, serviceUuid, characteristicUuid, notify) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._setNotify(peripheralUuid, serviceUuid, characteristicUuid, notify).catch(err => {
      this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, false, err);
    });
  }

  async _setNotify (peripheralUuid, serviceUuid, characteristicUuid, notify) {
    const path = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
    if (!path) throw new Error('characteristic not found');
    const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
    const iface = proxy.getInterface(GATT_CHAR_IFACE);
    const props = proxy.getInterface(PROPS_IFACE);

    if (notify) {
      if (!this._charPropsListeners.has(path)) {
        const listener = (ifaceName, changed) => {
          if (ifaceName !== GATT_CHAR_IFACE) return;
          const c = unwrapDict(changed);
          if ('Value' in c) {
            const buf = Buffer.isBuffer(c.Value) ? c.Value : Buffer.from(c.Value);
            this.emit('read', peripheralUuid, serviceUuid, characteristicUuid, buf, true);
          }
        };
        props.on('PropertiesChanged', listener);
        this._charPropsListeners.set(path, { props, listener });
      }
      await iface.StartNotify();
      this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, true);
    } else {
      try {
        await iface.StopNotify();
      } catch (err) {
        debug('StopNotify failed: %s', err.message);
      }
      const entry = this._charPropsListeners.get(path);
      if (entry) {
        entry.props.off('PropertiesChanged', entry.listener);
        this._charPropsListeners.delete(path);
      }
      this.emit('notify', peripheralUuid, serviceUuid, characteristicUuid, false);
    }
  }

  discoverDescriptors (peripheralUuid, serviceUuid, characteristicUuid) {
    peripheralUuid = normalizeId(peripheralUuid);
    const charPath = this._findCharacteristicPath(peripheralUuid, serviceUuid, characteristicUuid);
    if (!charPath) {
      this.emit('descriptorsDiscover', peripheralUuid, serviceUuid, characteristicUuid, [], new Error('characteristic not found'));
      return;
    }
    const descs = this._findDescriptorsForCharacteristic(charPath).map(d => d.uuid);
    this.emit('descriptorsDiscover', peripheralUuid, serviceUuid, characteristicUuid, descs);
  }

  readValue (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._readDesc(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid).catch(err => {
      this.emit('valueRead', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, null, err);
    });
  }

  async _readDesc (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid) {
    const path = this._findDescriptorPath(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid);
    if (!path) throw new Error('descriptor not found');
    const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
    const iface = proxy.getInterface(GATT_DESC_IFACE);
    const value = await iface.ReadValue({});
    const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
    this.emit('valueRead', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, buf);
  }

  writeValue (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data) {
    peripheralUuid = normalizeId(peripheralUuid);
    this._writeDesc(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data).catch(err => {
      this.emit('valueWrite', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, err);
    });
  }

  async _writeDesc (peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid, data) {
    const path = this._findDescriptorPath(peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid);
    if (!path) throw new Error('descriptor not found');
    const proxy = await this._bus.getProxyObject(BLUEZ_SERVICE, path);
    const iface = proxy.getInterface(GATT_DESC_IFACE);
    const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
    await iface.WriteValue(buf, {});
    this.emit('valueWrite', peripheralUuid, serviceUuid, characteristicUuid, descriptorUuid);
  }

  readHandle (peripheralUuid, handle) {
    peripheralUuid = normalizeId(peripheralUuid);
    const err = new Error('readHandle is not supported on the dbus backend (BlueZ exposes UUIDs only)');
    this.emit('handleRead', peripheralUuid, handle, null, err);
  }

  writeHandle (peripheralUuid, handle, _data, _withoutResponse) {
    peripheralUuid = normalizeId(peripheralUuid);
    const err = new Error('writeHandle is not supported on the dbus backend (BlueZ exposes UUIDs only)');
    this.emit('handleWrite', peripheralUuid, handle, err);
  }

  addressToId (address) {
    return addressToId(address);
  }
}

module.exports = DbusBindings;
