/* 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/. */
"use strict";

/**
 * This implements a UDP mulitcast device discovery protocol that:
 *   * Is optimized for mobile devices
 *   * Doesn't require any special schema for service info
 *
 * To ensure it works well on mobile devices, there is no heartbeat or other
 * recurring transmission.
 *
 * Devices are typically in one of two groups: scanning for services or
 * providing services (though they may be in both groups as well).
 *
 * Scanning devices listen on UPDATE_PORT for UDP multicast traffic.  When the
 * scanning device wants to force an update of the services available, it sends
 * a status packet to SCAN_PORT.
 *
 * Service provider devices listen on SCAN_PORT for any packets from scanning
 * devices.  If one is recevied, the provider device sends a status packet
 * (listing the services it offers) to UPDATE_PORT.
 *
 * Scanning devices purge any previously known devices after REPLY_TIMEOUT ms
 * from that start of a scan if no reply is received during the most recent
 * scan.
 *
 * When a service is registered, is supplies a regular object with any details
 * about itself (a port number, for example) in a service-defined format, which
 * is then available to scanning devices.
 */

const { Cu, CC, Cc, Ci } = require("chrome");
const EventEmitter = require("devtools/shared/event-emitter");
const Services = require("Services");

const UDPSocket = CC("@mozilla.org/network/udp-socket;1",
                     "nsIUDPSocket",
                     "init");

const SCAN_PORT = 50624;
const UPDATE_PORT = 50625;
const ADDRESS = "224.0.0.115";
const REPLY_TIMEOUT = 5000;

const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});

XPCOMUtils.defineLazyGetter(this, "converter", () => {
  let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
             createInstance(Ci.nsIScriptableUnicodeConverter);
  conv.charset = "utf8";
  return conv;
});

XPCOMUtils.defineLazyGetter(this, "sysInfo", () => {
  return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2);
});

XPCOMUtils.defineLazyGetter(this, "libcutils", function () {
  let { libcutils } = Cu.import("resource://gre/modules/systemlibs.js", {});
  return libcutils;
});

var logging = Services.prefs.getBoolPref("devtools.discovery.log");
function log(msg) {
  if (logging) {
    console.log("DISCOVERY: " + msg);
  }
}

/**
 * Each Transport instance owns a single UDPSocket.
 * @param port integer
 *        The port to listen on for incoming UDP multicast packets.
 */
function Transport(port) {
  EventEmitter.decorate(this);
  try {
    this.socket = new UDPSocket(port, false, Services.scriptSecurityManager.getSystemPrincipal());
    this.socket.joinMulticast(ADDRESS);
    this.socket.asyncListen(this);
  } catch (e) {
    log("Failed to start new socket: " + e);
  }
}

Transport.prototype = {

  /**
   * Send a object to some UDP port.
   * @param object object
   *        Object which is the message to send
   * @param port integer
   *        UDP port to send the message to
   */
  send: function (object, port) {
    if (logging) {
      log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
    }
    let message = JSON.stringify(object);
    let rawMessage = converter.convertToByteArray(message);
    try {
      this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
    } catch (e) {
      log("Failed to send message: " + e);
    }
  },

  destroy: function () {
    this.socket.close();
  },

  // nsIUDPSocketListener

  onPacketReceived: function (socket, message) {
    let messageData = message.data;
    let object = JSON.parse(messageData);
    object.from = message.fromAddr.address;
    let port = message.fromAddr.port;
    if (port == this.socket.port) {
      log("Ignoring looped message");
      return;
    }
    if (logging) {
      log("Recv on " + this.socket.port + ":\n" +
          JSON.stringify(object, null, 2));
    }
    this.emit("message", object);
  },

  onStopListening: function () {}

};

/**
 * Manages the local device's name.  The name can be generated in serveral
 * platform-specific ways (see |_generate|).  The aim is for each device on the
 * same local network to have a unique name.  If the Settings API is available,
 * the name is saved there to persist across reboots.
 */
function LocalDevice() {
  this._name = LocalDevice.UNKNOWN;
  if ("@mozilla.org/settingsService;1" in Cc) {
    this._settings =
      Cc["@mozilla.org/settingsService;1"].getService(Ci.nsISettingsService);
    Services.obs.addObserver(this, "mozsettings-changed", false);
  }
  this._get(); // Trigger |_get| to load name eagerly
}

LocalDevice.SETTING = "devtools.discovery.device";
LocalDevice.UNKNOWN = "unknown";

LocalDevice.prototype = {

  _get: function () {
    if (!this._settings) {
      // Without Settings API, just generate a name and stop, since the value
      // can't be persisted.
      this._generate();
      return;
    }
    // Initial read of setting value
    this._settings.createLock().get(LocalDevice.SETTING, {
      handle: (_, name) => {
        if (name && name !== LocalDevice.UNKNOWN) {
          this._name = name;
          log("Device: " + this._name);
          return;
        }
        // No existing name saved, so generate one.
        this._generate();
      },
      handleError: () => log("Failed to get device name setting")
    });
  },

  /**
   * Generate a new device name from various platform-specific properties.
   * Triggers the |name| setter to persist if needed.
   */
  _generate: function () {
    if (Services.appinfo.widgetToolkit == "android") {
      // For Firefox for Android, use the device's model name.
      // TODO: Bug 1180997: Find the right way to expose an editable name
      this.name = sysInfo.get("device");
    } else {
      this.name = sysInfo.get("host");
    }
  },

  /**
   * Observe any changes that might be made via the Settings app
   */
  observe: function (subject, topic, data) {
    if (topic !== "mozsettings-changed") {
      return;
    }
    if ("wrappedJSObject" in subject) {
      subject = subject.wrappedJSObject;
    }
    if (subject.key !== LocalDevice.SETTING) {
      return;
    }
    this._name = subject.value;
    log("Device: " + this._name);
  },

  get name() {
    return this._name;
  },

  set name(name) {
    if (!this._settings) {
      this._name = name;
      log("Device: " + this._name);
      return;
    }
    // Persist to Settings API
    // The new value will be seen and stored by the observer above
    this._settings.createLock().set(LocalDevice.SETTING, name, {
      handle: () => {},
      handleError: () => log("Failed to set device name setting")
    });
  }

};

function Discovery() {
  EventEmitter.decorate(this);

  this.localServices = {};
  this.remoteServices = {};
  this.device = new LocalDevice();
  this.replyTimeout = REPLY_TIMEOUT;

  // Defaulted to Transport, but can be altered by tests
  this._factories = { Transport: Transport };

  this._transports = {
    scan: null,
    update: null
  };
  this._expectingReplies = {
    from: new Set()
  };

  this._onRemoteScan = this._onRemoteScan.bind(this);
  this._onRemoteUpdate = this._onRemoteUpdate.bind(this);
  this._purgeMissingDevices = this._purgeMissingDevices.bind(this);
}

Discovery.prototype = {

  /**
   * Add a new service offered by this device.
   * @param service string
   *        Name of the service
   * @param info object
   *        Arbitrary data about the service to announce to scanning devices
   */
  addService: function (service, info) {
    log("ADDING LOCAL SERVICE");
    if (Object.keys(this.localServices).length === 0) {
      this._startListeningForScan();
    }
    this.localServices[service] = info;
  },

  /**
   * Remove a service offered by this device.
   * @param service string
   *        Name of the service
   */
  removeService: function (service) {
    delete this.localServices[service];
    if (Object.keys(this.localServices).length === 0) {
      this._stopListeningForScan();
    }
  },

  /**
   * Scan for service updates from other devices.
   */
  scan: function () {
    this._startListeningForUpdate();
    this._waitForReplies();
    // TODO Bug 1027457: Use timer to debounce
    this._sendStatusTo(SCAN_PORT);
  },

  /**
   * Get a list of all remote devices currently offering some service.:w
   */
  getRemoteDevices: function () {
    let devices = new Set();
    for (let service in this.remoteServices) {
      for (let device in this.remoteServices[service]) {
        devices.add(device);
      }
    }
    return [...devices];
  },

  /**
   * Get a list of all remote devices currently offering a particular service.
   */
  getRemoteDevicesWithService: function (service) {
    let devicesWithService = this.remoteServices[service] || {};
    return Object.keys(devicesWithService);
  },

  /**
   * Get service info (any details registered by the remote device) for a given
   * service on a device.
   */
  getRemoteService: function (service, device) {
    let devicesWithService = this.remoteServices[service] || {};
    return devicesWithService[device];
  },

  _waitForReplies: function () {
    clearTimeout(this._expectingReplies.timer);
    this._expectingReplies.from = new Set(this.getRemoteDevices());
    this._expectingReplies.timer =
      setTimeout(this._purgeMissingDevices, this.replyTimeout);
  },

  get Transport() {
    return this._factories.Transport;
  },

  _startListeningForScan: function () {
    if (this._transports.scan) {
      return; // Already listening
    }
    log("LISTEN FOR SCAN");
    this._transports.scan = new this.Transport(SCAN_PORT);
    this._transports.scan.on("message", this._onRemoteScan);
  },

  _stopListeningForScan: function () {
    if (!this._transports.scan) {
      return; // Not listening
    }
    this._transports.scan.off("message", this._onRemoteScan);
    this._transports.scan.destroy();
    this._transports.scan = null;
  },

  _startListeningForUpdate: function () {
    if (this._transports.update) {
      return; // Already listening
    }
    log("LISTEN FOR UPDATE");
    this._transports.update = new this.Transport(UPDATE_PORT);
    this._transports.update.on("message", this._onRemoteUpdate);
  },

  _stopListeningForUpdate: function () {
    if (!this._transports.update) {
      return; // Not listening
    }
    this._transports.update.off("message", this._onRemoteUpdate);
    this._transports.update.destroy();
    this._transports.update = null;
  },

  _restartListening: function () {
    if (this._transports.scan) {
      this._stopListeningForScan();
      this._startListeningForScan();
    }
    if (this._transports.update) {
      this._stopListeningForUpdate();
      this._startListeningForUpdate();
    }
  },

  /**
   * When sending message, we can use either transport, so just pick the first
   * one currently alive.
   */
  get _outgoingTransport() {
    if (this._transports.scan) {
      return this._transports.scan;
    }
    if (this._transports.update) {
      return this._transports.update;
    }
    return null;
  },

  _sendStatusTo: function (port) {
    let status = {
      device: this.device.name,
      services: this.localServices
    };
    this._outgoingTransport.send(status, port);
  },

  _onRemoteScan: function () {
    // Send my own status in response
    log("GOT SCAN REQUEST");
    this._sendStatusTo(UPDATE_PORT);
  },

  _onRemoteUpdate: function (e, update) {
    log("GOT REMOTE UPDATE");

    let remoteDevice = update.device;
    let remoteHost = update.from;

    // Record the reply as received so it won't be purged as missing
    this._expectingReplies.from.delete(remoteDevice);

    // First, loop over the known services
    for (let service in this.remoteServices) {
      let devicesWithService = this.remoteServices[service];
      let hadServiceForDevice = !!devicesWithService[remoteDevice];
      let haveServiceForDevice = service in update.services;
      // If the remote device used to have service, but doesn't any longer, then
      // it was deleted, so we remove it here.
      if (hadServiceForDevice && !haveServiceForDevice) {
        delete devicesWithService[remoteDevice];
        log("REMOVED " + service + ", DEVICE " + remoteDevice);
        this.emit(service + "-device-removed", remoteDevice);
      }
    }

    // Second, loop over the services in the received update
    for (let service in update.services) {
      // Detect if this is a new device for this service
      let newDevice = !this.remoteServices[service] ||
                      !this.remoteServices[service][remoteDevice];

      // Look up the service info we may have received previously from the same
      // remote device
      let devicesWithService = this.remoteServices[service] || {};
      let oldDeviceInfo = devicesWithService[remoteDevice];

      // Store the service info from the remote device
      let newDeviceInfo = Cu.cloneInto(update.services[service], {});
      newDeviceInfo.host = remoteHost;
      devicesWithService[remoteDevice] = newDeviceInfo;
      this.remoteServices[service] = devicesWithService;

      // If this is a new service for the remote device, announce the addition
      if (newDevice) {
        log("ADDED " + service + ", DEVICE " + remoteDevice);
        this.emit(service + "-device-added", remoteDevice, newDeviceInfo);
      }

      // If we've seen this service from the remote device, but the details have
      // changed, announce the update
      if (!newDevice &&
          JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo)) {
        log("UPDATED " + service + ", DEVICE " + remoteDevice);
        this.emit(service + "-device-updated", remoteDevice, newDeviceInfo);
      }
    }
  },

  _purgeMissingDevices: function () {
    log("PURGING MISSING DEVICES");
    for (let service in this.remoteServices) {
      let devicesWithService = this.remoteServices[service];
      for (let remoteDevice in devicesWithService) {
        // If we're still expecting a reply from a remote device when it's time
        // to purge, then the service is removed.
        if (this._expectingReplies.from.has(remoteDevice)) {
          delete devicesWithService[remoteDevice];
          log("REMOVED " + service + ", DEVICE " + remoteDevice);
          this.emit(service + "-device-removed", remoteDevice);
        }
      }
    }
  }

};

var discovery = new Discovery();

module.exports = discovery;
