/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil; tab-width: 2 -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */
/* jshint esnext: true, moz: true */

'use strict';

this.EXPORTED_SYMBOLS = ['MulticastDNS'];

const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

Cu.import('resource://gre/modules/Services.jsm');
Cu.import('resource://gre/modules/Timer.jsm');
Cu.import('resource://gre/modules/XPCOMUtils.jsm');

Cu.import('resource://gre/modules/DNSPacket.jsm');
Cu.import('resource://gre/modules/DNSRecord.jsm');
Cu.import('resource://gre/modules/DNSResourceRecord.jsm');
Cu.import('resource://gre/modules/DNSTypes.jsm');

const NS_NETWORK_LINK_TOPIC = 'network:link-status-changed';

let observerService     = Cc["@mozilla.org/observer-service;1"]
                            .getService(Components.interfaces.nsIObserverService);
let networkInfoService  = Cc['@mozilla.org/network-info-service;1']
                            .createInstance(Ci.nsINetworkInfoService);

const DEBUG = true;

const MDNS_MULTICAST_GROUP = '224.0.0.251';
const MDNS_PORT            = 5353;
const DEFAULT_TTL          = 120;

function debug(msg) {
  dump('MulticastDNS: ' + msg + '\n');
}

function ServiceKey(svc) {
  return "" + svc.serviceType.length + "/" + svc.serviceType + "|" +
              svc.serviceName.length + "/" + svc.serviceName + "|" +
              svc.port;
}

function TryGet(obj, name) {
  try {
    return obj[name];
  } catch (err) {
    return undefined;
  }
}

function IsIpv4Address(addr) {
  let parts = addr.split('.');
  if (parts.length != 4) {
    return false;
  }
  for (let part of parts) {
    let partInt = Number.parseInt(part, 10);
    if (partInt.toString() != part) {
      return false;
    }
    if (partInt < 0 || partInt >= 256) {
      return false;
    }
  }
  return true;
}

class PublishedService {
  constructor(attrs) {
    this.serviceType = attrs.serviceType.replace(/\.$/, '');
    this.serviceName = attrs.serviceName;
    this.domainName = TryGet(attrs, 'domainName') || "local";
    this.address = TryGet(attrs, 'address') || "0.0.0.0";
    this.port = attrs.port;
    this.serviceAttrs = _propertyBagToObject(TryGet(attrs, 'attributes') || {});
    this.host = TryGet(attrs, 'host');
    this.key = this.generateKey();
    this.lastAdvertised = undefined;
    this.advertiseTimer = undefined;
  }

  equals(svc) {
    return (this.port == svc.port) &&
           (this.serviceName == svc.serviceName) &&
           (this.serviceType == svc.serviceType);
  }

  generateKey() {
    return ServiceKey(this);
  }

  ptrMatch(name) {
    return name == (this.serviceType + "." + this.domainName);
  }

  clearAdvertiseTimer() {
    if (!this.advertiseTimer) {
      return;
    }
    clearTimeout(this.advertiseTimer);
    this.advertiseTimer = undefined;
  }
}

class MulticastDNS {
  constructor() {
    this._listeners       = new Map();
    this._sockets         = new Map();
    this._services        = new Map();
    this._discovered      = new Map();
    this._querySocket     = undefined;
    this._broadcastReceiverSocket = undefined;
    this._broadcastTimer  = undefined;

    this._networkLinkObserver = {
      observe: (subject, topic, data) => {
        DEBUG && debug(NS_NETWORK_LINK_TOPIC + '(' + data + '); Clearing list of previously discovered services');
        this._discovered.clear();
      }
    };
  }

  _attachNetworkLinkObserver() {
    if (this._networkLinkObserverTimeout) {
      clearTimeout(this._networkLinkObserverTimeout);
    }

    if (!this._isNetworkLinkObserverAttached) {
      DEBUG && debug('Attaching observer ' + NS_NETWORK_LINK_TOPIC);
      observerService.addObserver(this._networkLinkObserver, NS_NETWORK_LINK_TOPIC, false);
      this._isNetworkLinkObserverAttached = true;
    }
  }

  _detachNetworkLinkObserver() {
    if (this._isNetworkLinkObserverAttached) {
      if (this._networkLinkObserverTimeout) {
        clearTimeout(this._networkLinkObserverTimeout);
      }

      this._networkLinkObserverTimeout = setTimeout(() => {
        DEBUG && debug('Detaching observer ' + NS_NETWORK_LINK_TOPIC);
        observerService.removeObserver(this._networkLinkObserver, NS_NETWORK_LINK_TOPIC);
        this._isNetworkLinkObserverAttached = false;
        this._networkLinkObserverTimeout = null;
      }, 5000);
    }
  }

  startDiscovery(aServiceType, aListener) {
    DEBUG && debug('startDiscovery("' + aServiceType + '")');
    let { serviceType } = _parseServiceDomainName(aServiceType);

    this._attachNetworkLinkObserver();
    this._addServiceListener(serviceType, aListener);

    try {
      this._query(serviceType + '.local');
      aListener.onDiscoveryStarted(serviceType);
    } catch (e) {
      DEBUG && debug('startDiscovery("' + serviceType + '") FAILED: ' + e);
      this._removeServiceListener(serviceType, aListener);
      aListener.onStartDiscoveryFailed(serviceType, Cr.NS_ERROR_FAILURE);
    }
  }

  stopDiscovery(aServiceType, aListener) {
    DEBUG && debug('stopDiscovery("' + aServiceType + '")');
    let { serviceType } = _parseServiceDomainName(aServiceType);

    this._detachNetworkLinkObserver();
    this._removeServiceListener(serviceType, aListener);

    aListener.onDiscoveryStopped(serviceType);

    this._checkCloseSockets();
  }

  resolveService(aServiceInfo, aListener) {
    DEBUG && debug('resolveService(): ' + aServiceInfo.serviceName);

    // Address info is already resolved during discovery
    setTimeout(() => aListener.onServiceResolved(aServiceInfo));
  }

  registerService(aServiceInfo, aListener) {
    DEBUG && debug('registerService(): ' + aServiceInfo.serviceName);

    // Initialize the broadcast receiver socket in case it
    // hasn't already been started so we can listen for
    // multicast queries/announcements on all interfaces.
    this._getBroadcastReceiverSocket();

    for (let name of ['port', 'serviceName', 'serviceType']) {
      if (!TryGet(aServiceInfo, name)) {
        aListener.onRegistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE);
        throw new Error('Invalid nsIDNSServiceInfo; Missing "' + name + '"');
      }
    }

    let publishedService;
    try {
      publishedService = new PublishedService(aServiceInfo);
    } catch (e) {
      DEBUG && debug("Error constructing PublishedService: " + e + " - " + e.stack);
      setTimeout(() => aListener.onRegistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE));
      return;
    }

    // Ensure such a service does not already exist.
    if (this._services.get(publishedService.key)) {
      setTimeout(() => aListener.onRegistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE));
      return;
    }

    // Make sure that the service addr is '0.0.0.0', or there is at least one
    // socket open on the address the service is open on.
    this._getSockets().then((sockets) => {
      if (publishedService.address != '0.0.0.0' && !sockets.get(publishedService.address)) {
        setTimeout(() => aListener.onRegistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE));
        return;
      }

      this._services.set(publishedService.key, publishedService);

      // Service registered.. call onServiceRegistered on next tick.
      setTimeout(() => aListener.onServiceRegistered(aServiceInfo));

      // Set a timeout to start advertising the service too.
      publishedService.advertiseTimer = setTimeout(() => {
        this._advertiseService(publishedService.key, /* firstAdv = */ true);
      });
    });
  }

  unregisterService(aServiceInfo, aListener) {
    DEBUG && debug('unregisterService(): ' + aServiceInfo.serviceName);

    let serviceKey;
    try {
      serviceKey = ServiceKey(aServiceInfo);
    } catch (e) {
      setTimeout(() => aListener.onUnregistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE));
      return;
    }

    let publishedService = this._services.get(serviceKey);
    if (!publishedService) {
      setTimeout(() => aListener.onUnregistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE));
      return;
    }

    // Clear any advertise timeout for this published service.
    publishedService.clearAdvertiseTimer();

    // Delete the service from the service map.
    if (!this._services.delete(serviceKey)) {
      setTimeout(() => aListener.onUnregistrationFailed(aServiceInfo, Cr.NS_ERROR_FAILURE));
      return;
    }

    // Check the broadcast timer again to rejig when it should run next.
    this._checkStartBroadcastTimer();

    // Check to see if sockets should be closed, and if so close them.
    this._checkCloseSockets();

    aListener.onServiceUnregistered(aServiceInfo);
  }

  _respondToQuery(serviceKey, message) {
    let address = message.fromAddr.address;
    let port = message.fromAddr.port;
    DEBUG && debug('_respondToQuery(): key=' + serviceKey + ', fromAddr='
                        + address + ":" + port);

    let publishedService = this._services.get(serviceKey);
    if (!publishedService) {
      debug("_respondToQuery Could not find service (key=" + serviceKey + ")");
      return;
    }

    DEBUG && debug('_respondToQuery(): key=' + serviceKey + ': SENDING RESPONSE');
    this._advertiseServiceHelper(publishedService, {address,port});
  }

  _advertiseService(serviceKey, firstAdv) {
    DEBUG && debug('_advertiseService(): key=' + serviceKey);
    let publishedService = this._services.get(serviceKey);
    if (!publishedService) {
      debug("_advertiseService Could not find service to advertise (key=" + serviceKey + ")");
      return;
    }

    publishedService.advertiseTimer = undefined;

    this._advertiseServiceHelper(publishedService, null).then(() => {
      // If first advertisement, re-advertise in 1 second.
      // Otherwise, set the lastAdvertised time.
      if (firstAdv) {
        publishedService.advertiseTimer = setTimeout(() => {
          this._advertiseService(serviceKey)
        }, 1000);
      } else {
        publishedService.lastAdvertised = Date.now();
        this._checkStartBroadcastTimer();
      }
    });
  }

  _advertiseServiceHelper(svc, target) {
    if (!target) {
      target = {address:MDNS_MULTICAST_GROUP, port:MDNS_PORT};
    }

    return this._getSockets().then((sockets) => {
      sockets.forEach((socket, address) => {
        if (svc.address == "0.0.0.0" || address == svc.address)
        {
          let packet = this._makeServicePacket(svc, [address]);
          let data = packet.serialize();
          try {
            socket.send(target.address, target.port, data, data.length);
          } catch (err) {
            DEBUG && debug("Failed to send packet to "
                            + target.address + ":" + target.port);
          }
        }
      });
    });
  }

  _cancelBroadcastTimer() {
    if (!this._broadcastTimer) {
      return;
    }
    clearTimeout(this._broadcastTimer);
    this._broadcastTimer = undefined;
  }

  _checkStartBroadcastTimer() {
    DEBUG && debug("_checkStartBroadcastTimer()");
    // Cancel any existing broadcasting timer.
    this._cancelBroadcastTimer();

    let now = Date.now();

    // Go through services and find services to broadcast.
    let bcastServices = [];
    let nextBcastWait = undefined;
    for (let [serviceKey, publishedService] of this._services) {
      // if lastAdvertised is undefined, service hasn't finished it's initial
      // two broadcasts.
      if (publishedService.lastAdvertised === undefined) {
        continue;
      }

      // Otherwise, check lastAdvertised against now.
      let msSinceAdv = now - publishedService.lastAdvertised;

      // If msSinceAdv is more than 90% of the way to the TTL, advertise now.
      if (msSinceAdv > (DEFAULT_TTL * 1000 * 0.9)) {
        bcastServices.push(publishedService);
        continue;
      }

      // Otherwise, calculate the next time to advertise for this service.
      // We set that at 95% of the time to the TTL expiry.
      let nextAdvWait = (DEFAULT_TTL * 1000 * 0.95) - msSinceAdv;
      if (nextBcastWait === undefined || nextBcastWait > nextAdvWait) {
        nextBcastWait = nextAdvWait;
      }
    }

    // Schedule an immediate advertisement of all services to be advertised now.
    for (let svc of bcastServices) {
        svc.advertiseTimer = setTimeout(() => this._advertiseService(svc.key));
    }

    // Schedule next broadcast check for the next bcast time.
    if (nextBcastWait !== undefined) {
      DEBUG && debug("_checkStartBroadcastTimer(): Scheduling next check in " + nextBcastWait + "ms");
      this._broadcastTimer = setTimeout(() => this._checkStartBroadcastTimer(), nextBcastWait);
    }
  }

  _query(name) {
    DEBUG && debug('query("' + name + '")');
    let packet = new DNSPacket();
    packet.setFlag('QR', DNS_QUERY_RESPONSE_CODES.QUERY);

    // PTR Record
    packet.addRecord('QD', new DNSRecord({
      name: name,
      recordType: DNS_RECORD_TYPES.PTR,
      classCode: DNS_CLASS_CODES.IN,
      cacheFlush: true
    }));

    let data = packet.serialize();

    // Initialize the broadcast receiver socket in case it
    // hasn't already been started so we can listen for
    // multicast queries/announcements on all interfaces.
    this._getBroadcastReceiverSocket();

    this._getQuerySocket().then((querySocket) => {
      DEBUG && debug('sending query on query socket ("' + name + '")');
      querySocket.send(MDNS_MULTICAST_GROUP, MDNS_PORT, data, data.length);
    });

    // Automatically announce previously-discovered
    // services that match and haven't expired yet.
    setTimeout(() => {
      DEBUG && debug('announcing previously discovered services ("' + name + '")');
      let { serviceType } = _parseServiceDomainName(name);

      this._clearExpiredDiscoveries();
      this._discovered.forEach((discovery, key) => {
        let serviceInfo = discovery.serviceInfo;
        if (serviceInfo.serviceType !== serviceType) {
          return;
        }

        let listeners = this._listeners.get(serviceInfo.serviceType) || [];
        listeners.forEach((listener) => {
          listener.onServiceFound(serviceInfo);
        });
      });
    });
  }

  _clearExpiredDiscoveries() {
    this._discovered.forEach((discovery, key) => {
      if (discovery.expireTime < Date.now()) {
        this._discovered.delete(key);
        return;
      }
    });
  }

  _handleQueryPacket(packet, message) {
    packet.getRecords(['QD']).forEach((record) => {
      // Don't respond if the query's class code is not IN or ANY.
      if (record.classCode !== DNS_CLASS_CODES.IN &&
          record.classCode !== DNS_CLASS_CODES.ANY) {
        return;
      }

      // Don't respond if the query's record type is not PTR or ANY.
      if (record.recordType !== DNS_RECORD_TYPES.PTR &&
          record.recordType !== DNS_RECORD_TYPES.ANY) {
        return;
      }

      for (let [serviceKey, publishedService] of this._services) {
        DEBUG && debug("_handleQueryPacket: " + packet.toJSON());
        if (publishedService.ptrMatch(record.name)) {
          this._respondToQuery(serviceKey, message);
        }
      }
    });
  }

  _makeServicePacket(service, addresses) {
    let packet = new DNSPacket();
    packet.setFlag('QR', DNS_QUERY_RESPONSE_CODES.RESPONSE);
    packet.setFlag('AA', DNS_AUTHORITATIVE_ANSWER_CODES.YES);

    let host = service.host || _hostname;

    // e.g.: foo-bar-service._http._tcp.local
    let serviceDomainName = service.serviceName + '.' + service.serviceType + '.local';

    // PTR Record
    packet.addRecord('AN', new DNSResourceRecord({
      name: service.serviceType + '.local', // e.g.: _http._tcp.local
      recordType: DNS_RECORD_TYPES.PTR,
      data: serviceDomainName
    }));

    // SRV Record
    packet.addRecord('AR', new DNSResourceRecord({
      name: serviceDomainName,
      recordType: DNS_RECORD_TYPES.SRV,
      classCode: DNS_CLASS_CODES.IN,
      cacheFlush: true,
      data: {
        priority: 0,
        weight: 0,
        port: service.port,
        target: host // e.g.: My-Android-Phone.local
      }
    }));

    // A Records
    for (let address of addresses) {
        packet.addRecord('AR', new DNSResourceRecord({
          name: host,
          recordType: DNS_RECORD_TYPES.A,
          data: address
        }));
    }

    // TXT Record
    packet.addRecord('AR', new DNSResourceRecord({
      name: serviceDomainName,
      recordType: DNS_RECORD_TYPES.TXT,
      classCode: DNS_CLASS_CODES.IN,
      cacheFlush: true,
      data: service.serviceAttrs || {}
    }));

    return packet;
  }

  _handleResponsePacket(packet, message) {
    let services = {};
    let hosts = {};

    let srvRecords = packet.getRecords(['AN', 'AR'], DNS_RECORD_TYPES.SRV);
    let txtRecords = packet.getRecords(['AN', 'AR'], DNS_RECORD_TYPES.TXT);
    let ptrRecords = packet.getRecords(['AN', 'AR'], DNS_RECORD_TYPES.PTR);
    let aRecords = packet.getRecords(['AN', 'AR'], DNS_RECORD_TYPES.A);

    srvRecords.forEach((record) => {
      let data = record.data || {};

      services[record.name] = {
        host: data.target,
        port: data.port,
        ttl: record.ttl
      };
    });

    txtRecords.forEach((record) => {
      if (!services[record.name]) {
        return;
      }

      services[record.name].attributes = record.data;
    });

    aRecords.forEach((record) => {
      if (IsIpv4Address(record.data)) {
        hosts[record.name] = record.data;
      }
    });

    ptrRecords.forEach((record) => {
      let name = record.data;
      if (!services[name]) {
        return;
      }

      let {host, port} = services[name];
      if (!host || !port) {
        return;
      }

      let { serviceName, serviceType, domainName } = _parseServiceDomainName(name);
      if (!serviceName || !serviceType || !domainName) {
        return;
      }

      let address = hosts[host];
      if (!address) {
        return;
      }

      let ttl = services[name].ttl || 0;
      let serviceInfo = {
        serviceName: serviceName,
        serviceType: serviceType,
        host: host,
        address: address,
        port: port,
        domainName: domainName,
        attributes: services[name].attributes || {}
      };

      this._onServiceFound(serviceInfo, ttl);
    });
  }

  _onServiceFound(serviceInfo, ttl = 0) {
    let expireTime = Date.now() + (ttl * 1000);
    let key = serviceInfo.serviceName + '.' +
              serviceInfo.serviceType + '.' +
              serviceInfo.domainName + ' @' +
              serviceInfo.address + ':' +
              serviceInfo.port;

    // If this service was already discovered, just update
    // its expiration time and don't re-emit it.
    if (this._discovered.has(key)) {
      this._discovered.get(key).expireTime = expireTime;
      return;
    }

    this._discovered.set(key, {
      serviceInfo: serviceInfo,
      expireTime: expireTime
    });

    let listeners = this._listeners.get(serviceInfo.serviceType) || [];
    listeners.forEach((listener) => {
      listener.onServiceFound(serviceInfo);
    });

    DEBUG && debug('_onServiceFound()' + serviceInfo.serviceName);
  }

  /**
   * Gets a non-exclusive socket on 0.0.0.0:{random} to send
   * multicast queries on all interfaces. This socket does
   * not need to join a multicast group since it is still
   * able to *send* multicast queries, but it does not need
   * to *listen* for multicast queries/announcements since
   * the `_broadcastReceiverSocket` is already handling them.
   */
  _getQuerySocket() {
    return new Promise((resolve, reject) => {
      if (!this._querySocket) {
        this._querySocket = _openSocket('0.0.0.0', 0, {
          onPacketReceived: this._onPacketReceived.bind(this),
          onStopListening: this._onStopListening.bind(this)
        });
      }
      resolve(this._querySocket);
    });
  }

  /**
   * Gets a non-exclusive socket on 0.0.0.0:5353 to listen
   * for multicast queries/announcements on all interfaces.
   * Since this socket needs to listen for multicast queries
   * and announcements, this socket joins the multicast
   * group on *all* interfaces (0.0.0.0).
   */
  _getBroadcastReceiverSocket() {
    return new Promise((resolve, reject) => {
      if (!this._broadcastReceiverSocket) {
        this._broadcastReceiverSocket = _openSocket('0.0.0.0', MDNS_PORT, {
          onPacketReceived: this._onPacketReceived.bind(this),
          onStopListening: this._onStopListening.bind(this)
        }, /* multicastInterface = */ '0.0.0.0');
      }
      resolve(this._broadcastReceiverSocket);
    });
  }

  /**
   * Gets a non-exclusive socket for each interface on
   * {iface-ip}:5353 for sending query responses as
   * well as for listening for unicast queries. These
   * sockets do not need to join a multicast group
   * since they are still able to *send* multicast
   * query responses, but they do not need to *listen*
   * for multicast queries since the `_querySocket` is
   * already handling them.
   */
  _getSockets() {
    return new Promise((resolve) => {
      if (this._sockets.size > 0) {
        resolve(this._sockets);
        return;
      }

      Promise.all([getAddresses(), getHostname()]).then(() => {
        _addresses.forEach((address) => {
          let socket = _openSocket(address, MDNS_PORT, null);
          this._sockets.set(address, socket);
        });

        resolve(this._sockets);
      });
    });
  }

  _checkCloseSockets() {
    // Nothing to do if no sockets to close.
    if (this._sockets.size == 0)
      return;

    // Don't close sockets if discovery listeners are still present.
    if (this._listeners.size > 0)
      return;

    // Don't close sockets if advertised services are present.
    // Since we need to listen for service queries and respond to them.
    if (this._services.size > 0)
      return;

    this._closeSockets();
  }

  _closeSockets() {
    this._sockets.forEach(socket => socket.close());
    this._sockets.clear();
  }

  _onPacketReceived(socket, message) {
    let packet = DNSPacket.parse(message.rawData);

    switch (packet.getFlag('QR')) {
      case DNS_QUERY_RESPONSE_CODES.QUERY:
        this._handleQueryPacket(packet, message);
        break;
      case DNS_QUERY_RESPONSE_CODES.RESPONSE:
        this._handleResponsePacket(packet, message);
        break;
      default:
        break;
    }
  }

  _onStopListening(socket, status) {
    DEBUG && debug('_onStopListening() ' + status);
  }

  _addServiceListener(serviceType, listener) {
    let listeners = this._listeners.get(serviceType);
    if (!listeners) {
      listeners = [];
      this._listeners.set(serviceType, listeners);
    }

    if (!listeners.find(l => l === listener)) {
      listeners.push(listener);
    }
  }

  _removeServiceListener(serviceType, listener) {
    let listeners = this._listeners.get(serviceType);
    if (!listeners) {
      return;
    }

    let index = listeners.findIndex(l => l === listener);
    if (index >= 0) {
      listeners.splice(index, 1);
    }

    if (listeners.length === 0) {
      this._listeners.delete(serviceType);
    }
  }
}

let _addresses;

/**
 * @private
 */
function getAddresses() {
  return new Promise((resolve, reject) => {
    if (_addresses) {
      resolve(_addresses);
      return;
    }

    networkInfoService.listNetworkAddresses({
      onListedNetworkAddresses(aAddressArray) {
        _addresses = aAddressArray.filter((address) => {
          return address.indexOf('%p2p') === -1 &&  // No WiFi Direct interfaces
                 address.indexOf(':')    === -1 &&  // XXX: No IPv6 for now
                 address != "127.0.0.1"             // No ipv4 loopback addresses.
        });

        DEBUG && debug('getAddresses(): ' + _addresses);
        resolve(_addresses);
      },

      onListNetworkAddressesFailed() {
        DEBUG && debug('getAddresses() FAILED!');
        resolve([]);
      }
    });
  });
}

let _hostname;

/**
 * @private
 */
function getHostname() {
  return new Promise((resolve) => {
    if (_hostname) {
      resolve(_hostname);
      return;
    }

    networkInfoService.getHostname({
      onGotHostname(aHostname) {
        _hostname = aHostname.replace(/\s/g, '-') + '.local';

        DEBUG && debug('getHostname(): ' + _hostname);
        resolve(_hostname);
      },

      onGetHostnameFailed() {
        DEBUG && debug('getHostname() FAILED');
        resolve('localhost');
      }
    });
  });
}

/**
 * Parse fully qualified domain name to service name, instance name,
 * and domain name. See https://tools.ietf.org/html/rfc6763#section-7.
 *
 * Example: 'foo-bar-service._http._tcp.local' -> {
 *   serviceName: 'foo-bar-service',
 *   serviceType: '_http._tcp',
 *   domainName: 'local'
 * }
 *
 * @private
 */
function _parseServiceDomainName(serviceDomainName) {
  let parts = serviceDomainName.split('.');
  let index = Math.max(parts.lastIndexOf('_tcp'), parts.lastIndexOf('_udp'));

  return {
    serviceName: parts.splice(0, index - 1).join('.'),
    serviceType: parts.splice(0, 2).join('.'),
    domainName: parts.join('.')
  };
}

/**
 * @private
 */
function _propertyBagToObject(propBag) {
  let result = {};
  if (propBag.QueryInterface) {
    propBag.QueryInterface(Ci.nsIPropertyBag2);
    let propEnum = propBag.enumerator;
    while (propEnum.hasMoreElements()) {
      let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
      result[prop.name] = prop.value.toString();
    }
  } else {
    for (let name in propBag) {
      result[name] = propBag[name].toString();
    }
  }
  return result;
}

/**
 * @private
 */
function _openSocket(addr, port, handler, multicastInterface) {
  let socket = Cc['@mozilla.org/network/udp-socket;1'].createInstance(Ci.nsIUDPSocket);
  socket.init2(addr, port, Services.scriptSecurityManager.getSystemPrincipal(), true);

  if (handler) {
    socket.asyncListen({
      onPacketReceived: handler.onPacketReceived,
      onStopListening: handler.onStopListening
    });
  }

  if (multicastInterface) {
    socket.joinMulticast(MDNS_MULTICAST_GROUP, multicastInterface);
  }

  return socket;
}