/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");

const UDPSocket = Components.Constructor(
  "@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;

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

/**
 * Each Transport instance owns a single UDPSocket.
 */
class Transport extends EventEmitter {
  /**
   * @param {Integer} port
   *        The port to listen on for incoming UDP multicast packets.
   */
  constructor(port) {
    super();

    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);
    }
  }
  /**
   * Send a object to some UDP port.
   *
   * @param {object} object
   *        Object which is the message to send
   * @param {Integer} port
   *        UDP port to send the message to
   */
  send(object, port) {
    if (logging) {
      log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
    }
    const message = JSON.stringify(object);
    const rawMessage = Uint8Array.from(message, x => x.charCodeAt(0));
    try {
      this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
    } catch (e) {
      log("Failed to send message: " + e);
    }
  }

  destroy() {
    this.socket.close();
  }

  // nsIUDPSocketListener
  onPacketReceived(socket, message) {
    const messageData = message.data;
    const object = JSON.parse(messageData);
    object.from = message.fromAddr.address;
    const 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() {}
}

/**
 * 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.
 */
class LocalDevice {
  static UNKNOWN = "unknown";
  constructor() {
    this._name = LocalDevice.UNKNOWN;
    // Trigger |_get| to load name eagerly
    this._get();
  }
  _get() {
    // Without Settings API, just generate a name and stop, since the value
    // can't be persisted.
    this._generate();
  }

  /**
   * Generate a new device name from various platform-specific properties.
   * Triggers the |name| setter to persist if needed.
   */
  _generate() {
    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 = Services.sysinfo.get("device");
    } else {
      this.name = Services.dns.myHostName;
    }
  }

  get name() {
    return this._name;
  }

  set name(name) {
    this._name = name;
    log("Device: " + this._name);
  }
}

class Discovery extends EventEmitter {
  constructor() {
    super();

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

    // Defaulted to Transport, but can be altered by tests
    this._factories = { 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);
  }
  /**
   * Add a new service offered by this device.
   *
   * @param {string} service
   *        Name of the service
   * @param {object} info
   *        Arbitrary data about the service to announce to scanning devices
   */
  addService(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 {string} service
   *        Name of the service
   */
  removeService(service) {
    delete this.localServices[service];
    if (Object.keys(this.localServices).length === 0) {
      this._stopListeningForScan();
    }
  }

  /**
   * Scan for service updates from other devices.
   */
  scan() {
    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.
   */
  getRemoteDevices() {
    const devices = new Set();
    for (const service in this.remoteServices) {
      for (const device in this.remoteServices[service]) {
        devices.add(device);
      }
    }
    return [...devices];
  }

  /**
   * Get a list of all remote devices currently offering a particular service.
   */
  getRemoteDevicesWithService(service) {
    const 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(service, device) {
    const devicesWithService = this.remoteServices[service] || {};
    return devicesWithService[device];
  }

  _waitForReplies() {
    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() {
    if (this._transports.scan) {
      // Already listening
      return;
    }
    log("LISTEN FOR SCAN");
    this._transports.scan = new this.Transport(SCAN_PORT);
    this._transports.scan.on("message", this._onRemoteScan);
  }

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

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

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

  _restartListening() {
    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(port) {
    const status = {
      device: this.device.name,
      services: this.localServices,
    };
    this._outgoingTransport.send(status, port);
  }

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

  _onRemoteUpdate(update) {
    log("GOT REMOTE UPDATE");

    const remoteDevice = update.device;
    const 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 (const service in this.remoteServices) {
      const devicesWithService = this.remoteServices[service];
      const hadServiceForDevice = !!devicesWithService[remoteDevice];
      const 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 (const service in update.services) {
      // Detect if this is a new device for this service
      const newDevice =
        !this.remoteServices[service] ||
        !this.remoteServices[service][remoteDevice];

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

      // Store the service info from the remote device
      const 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() {
    log("PURGING MISSING DEVICES");
    for (const service in this.remoteServices) {
      const devicesWithService = this.remoteServices[service];
      for (const 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;
