/* global chrome */

/**
 * UDP / Datagram Sockets
 * ======================
 *
 * Datagram sockets are available through require('chrome-dgram').
 */

exports.Socket = Socket

var EventEmitter = require('events').EventEmitter
var inherits = require('inherits')
var series = require('run-series')

var BIND_STATE_UNBOUND = 0
var BIND_STATE_BINDING = 1
var BIND_STATE_BOUND = 2

// Track open sockets to route incoming data (via onReceive) to the right handlers.
var sockets = {}

// Thorough check for Chrome App since both Edge and Chrome implement dummy chrome object
if (
  typeof chrome === 'object' &&
  typeof chrome.runtime === 'object' &&
  typeof chrome.runtime.id === 'string' &&
  typeof chrome.sockets === 'object' &&
  typeof chrome.sockets.udp === 'object'
) {
  chrome.sockets.udp.onReceive.addListener(onReceive)
  chrome.sockets.udp.onReceiveError.addListener(onReceiveError)
} else {
  console.error('Undefined chrome ref, could not register on-receive listener')
}

function onReceive (info) {
  if (info.socketId in sockets) {
    sockets[info.socketId]._onReceive(info)
  } else {
    console.error('Unknown socket id: ' + info.socketId)
  }
}

function onReceiveError (info) {
  if (info.socketId in sockets) {
    sockets[info.socketId]._onReceiveError(info.resultCode)
  } else {
    console.error('Unknown socket id: ' + info.socketId)
  }
}

/**
 * dgram.createSocket(type, [callback])
 *
 * Creates a datagram Socket of the specified types. Valid types are `udp4`
 * and `udp6`.
 *
 * Takes an optional callback which is added as a listener for message events.
 *
 * Call socket.bind if you want to receive datagrams. socket.bind() will bind
 * to the "all interfaces" address on a random port (it does the right thing
 * for both udp4 and udp6 sockets). You can then retrieve the address and port
 * with socket.address().address and socket.address().port.
 *
 * @param  {string} type       Either 'udp4' or 'udp6'
 * @param  {function} listener Attached as a listener to message events.
 *                             Optional
 * @return {Socket}            Socket object
 */
exports.createSocket = function (type, listener) {
  return new Socket(type, listener)
}

inherits(Socket, EventEmitter)

/**
 * Class: dgram.Socket
 *
 * The dgram Socket class encapsulates the datagram functionality. It should
 * be created via `dgram.createSocket(type, [callback])`.
 *
 * Event: 'message'
 *   - msg Buffer object. The message
 *   - rinfo Object. Remote address information
 *   Emitted when a new datagram is available on a socket. msg is a Buffer and
 *   rinfo is an object with the sender's address information and the number
 *   of bytes in the datagram.
 *
 * Event: 'listening'
 *   Emitted when a socket starts listening for datagrams. This happens as soon
 *   as UDP sockets are created.
 *
 * Event: 'close'
 *   Emitted when a socket is closed with close(). No new message events will
 *   be emitted on this socket.
 *
 * Event: 'error'
 *   - exception Error object
 *   Emitted when an error occurs.
 */
function Socket (options, listener) {
  var self = this
  EventEmitter.call(self)
  if (typeof options === 'string') options = { type: options }
  if (options.type !== 'udp4') throw new Error('Bad socket type specified. Valid types are: udp4')

  if (typeof listener === 'function') self.on('message', listener)

  self._destroyed = false
  self._bindState = BIND_STATE_UNBOUND
  self._bindTasks = []
}

/**
 * socket.bind(port, [address], [callback])
 *
 * For UDP sockets, listen for datagrams on a named port and optional address.
 * If address is not specified, the OS will try to listen on all addresses.
 * After binding is done, a "listening" event is emitted and the callback(if
 * specified) is called. Specifying both a "listening" event listener and
 * callback is not harmful but not very useful.
 *
 * A bound datagram socket keeps the node process running to receive
 * datagrams.
 *
 * If binding fails, an "error" event is generated. In rare case (e.g. binding
 * a closed socket), an Error may be thrown by this method.
 *
 * @param {number} port
 * @param {string} address Optional
 * @param {function} callback Function with no parameters, Optional. Callback
 *                            when binding is done.
 */
Socket.prototype.bind = function (port, address, callback) {
  var self = this
  if (typeof address === 'function') {
    callback = address
    address = undefined
  }

  if (!address) address = '0.0.0.0'

  if (!port) port = 0

  if (self._bindState !== BIND_STATE_UNBOUND) throw new Error('Socket is already bound')

  self._bindState = BIND_STATE_BINDING

  if (typeof callback === 'function') self.once('listening', callback)

  chrome.sockets.udp.create(function (createInfo) {
    self.id = createInfo.socketId

    sockets[self.id] = self

    var bindFns = self._bindTasks.map(function (t) { return t.fn })

    series(bindFns, function (err) {
      if (err) return self.emit('error', err)
      chrome.sockets.udp.bind(self.id, address, port, function (result) {
        if (result < 0) {
          self.emit('error', new Error('Socket ' + self.id + ' failed to bind. ' +
            chrome.runtime.lastError.message))
          return
        }
        chrome.sockets.udp.getInfo(self.id, function (socketInfo) {
          if (!socketInfo.localPort || !socketInfo.localAddress) {
            self.emit('error', new Error('Cannot get local port/address for Socket ' + self.id))
            return
          }

          self._port = socketInfo.localPort
          self._address = socketInfo.localAddress

          self._bindState = BIND_STATE_BOUND
          self.emit('listening')

          self._bindTasks.map(function (t) {
            t.callback()
          })
        })
      })
    })
  })
}

/**
 * Internal function to receive new messages and emit `message` events.
 */
Socket.prototype._onReceive = function (info) {
  var self = this

  var buf = Buffer.from(new Uint8Array(info.data))
  var rinfo = {
    address: info.remoteAddress,
    family: 'IPv4',
    port: info.remotePort,
    size: buf.length
  }
  self.emit('message', buf, rinfo)
}

Socket.prototype._onReceiveError = function (resultCode) {
  var self = this
  self.emit('error', new Error('Socket ' + self.id + ' receive error ' + resultCode))
}

/**
 * socket.send(buf, offset, length, port, address, [callback])
 *
 * For UDP sockets, the destination port and IP address must be
 * specified. A string may be supplied for the address parameter, and it will
 * be resolved with DNS. An optional callback may be specified to detect any
 * DNS errors and when buf may be re-used. Note that DNS lookups will delay
 * the time that a send takes place, at least until the next tick. The only
 * way to know for sure that a send has taken place is to use the callback.
 *
 * If the socket has not been previously bound with a call to bind, it's
 * assigned a random port number and bound to the "all interfaces" address
 * (0.0.0.0 for udp4 sockets, ::0 for udp6 sockets).
 *
 * @param {Buffer|Arrayish|string} buf Message to be sent
 * @param {number} offset Offset in the buffer where the message starts. Optional.
 * @param {number} length Number of bytes in the message. Optional.
 * @param {number} port destination port
 * @param {string} address destination IP
 * @param {function} callback Callback when message is done being delivered.
 *                            Optional.
 *
 * Valid combinations:
 *   send(buffer, offset, length, port, address, callback)
 *   send(buffer, offset, length, port, address)
 *   send(buffer, offset, length, port)
 *   send(bufferOrList, port, address, callback)
 *   send(bufferOrList, port, address)
 *   send(bufferOrList, port)
 *
 */
Socket.prototype.send = function (buffer, offset, length, port, address, callback) {
  var self = this

  var list

  if (address || (port && typeof port !== 'function')) {
    buffer = sliceBuffer(buffer, offset, length)
  } else {
    callback = port
    port = offset
    address = length
  }

  if (!Array.isArray(buffer)) {
    if (typeof buffer === 'string') {
      list = [Buffer.from(buffer)]
    } else if (!(buffer instanceof Buffer)) {
      throw new TypeError('First argument must be a buffer or a string')
    } else {
      list = [buffer]
    }
  } else if (!(list = fixBufferList(buffer))) {
    throw new TypeError('Buffer list arguments must be buffers or strings')
  }

  port = port >>> 0
  if (port === 0 || port > 65535) {
    throw new RangeError('Port should be > 0 and < 65536')
  }

  // Normalize callback so it's always a function
  if (typeof callback !== 'function') {
    callback = function () {}
  }

  if (self._bindState === BIND_STATE_UNBOUND) self.bind(0)

  // If the socket hasn't been bound yet, push the outbound packet onto the
  // send queue and send after binding is complete.
  if (self._bindState !== BIND_STATE_BOUND) {
    // If the send queue hasn't been initialized yet, do it, and install an
    // event handler that flishes the send queue after binding is done.
    if (!self._sendQueue) {
      self._sendQueue = []
      self.once('listening', function () {
        // Flush the send queue.
        for (var i = 0; i < self._sendQueue.length; i++) {
          self.send.apply(self, self._sendQueue[i])
        }
        self._sendQueue = undefined
      })
    }
    self._sendQueue.push([buffer, offset, length, port, address, callback])
    return
  }

  var ab = Buffer.concat(list).buffer

  chrome.sockets.udp.send(self.id, ab, address, port, function (sendInfo) {
    if (sendInfo.resultCode < 0) {
      var err = new Error('Socket ' + self.id + ' send error ' + sendInfo.resultCode)
      callback(err)
      self.emit('error', err)
    } else {
      callback(null)
    }
  })
}

function sliceBuffer (buffer, offset, length) {
  if (typeof buffer === 'string') {
    buffer = Buffer.from(buffer)
  } else if (!(buffer instanceof Buffer)) {
    throw new TypeError('First argument must be a buffer or string')
  }

  offset = offset >>> 0
  length = length >>> 0

  // assuming buffer is browser implementation (`buffer` package on npm)
  var buf = buffer.buffer
  if (buffer.byteOffset || buffer.byteLength !== buf.byteLength) {
    buf = buf.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
  }
  if (offset || length !== buffer.length) {
    buf = buf.slice(offset, length)
  }

  return Buffer.from(buf)
}

function fixBufferList (list) {
  var newlist = new Array(list.length)

  for (var i = 0, l = list.length; i < l; i++) {
    var buf = list[i]
    if (typeof buf === 'string') {
      newlist[i] = Buffer.from(buf)
    } else if (!(buf instanceof Buffer)) {
      return null
    } else {
      newlist[i] = buf
    }
  }

  return newlist
}

/**
 * Close the underlying socket and stop listening for data on it.
 */
Socket.prototype.close = function () {
  var self = this
  if (self._destroyed) return

  delete sockets[self.id]
  chrome.sockets.udp.close(self.id)
  self._destroyed = true

  self.emit('close')
}

/**
 * Returns an object containing the address information for a socket. For UDP
 * sockets, this object will contain address, family and port.
 *
 * @return {Object} information
 */
Socket.prototype.address = function () {
  var self = this
  return {
    address: self._address,
    port: self._port,
    family: 'IPv4'
  }
}

Socket.prototype.setBroadcast = function (flag) {
  // No chrome.sockets equivalent
}

Socket.prototype.setTTL = function (ttl) {
  // No chrome.sockets equivalent
}

// NOTE: Multicast code is untested. Pull requests accepted for bug fixes and to
// add tests!

/**
 * Sets the IP_MULTICAST_TTL socket option. TTL stands for "Time to Live," but
 * in this context it specifies the number of IP hops that a packet is allowed
 * to go through, specifically for multicast traffic. Each router or gateway
 * that forwards a packet decrements the TTL. If the TTL is decremented to 0
 * by a router, it will not be forwarded.
 *
 * The argument to setMulticastTTL() is a number of hops between 0 and 255.
 * The default on most systems is 1.
 *
 * NOTE: The Chrome version of this function is async, whereas the node
 * version is sync. Keep this in mind.
 *
 * @param {number} ttl
 * @param {function} callback CHROME-SPECIFIC: Called when the configuration
 *                            operation is done.
 */
Socket.prototype.setMulticastTTL = function (ttl, callback) {
  var self = this
  if (!callback) callback = function () {}
  if (self._bindState === BIND_STATE_BOUND) {
    setMulticastTTL(callback)
  } else {
    self._bindTasks.push({
      fn: setMulticastTTL,
      callback: callback
    })
  }

  function setMulticastTTL (callback) {
    chrome.sockets.udp.setMulticastTimeToLive(self.id, ttl, callback)
  }
}

/**
 * Sets or clears the IP_MULTICAST_LOOP socket option. When this option is
 * set, multicast packets will also be received on the local interface.
 *
 * NOTE: The Chrome version of this function is async, whereas the node
 * version is sync. Keep this in mind.
 *
 * @param {boolean} flag
 * @param {function} callback CHROME-SPECIFIC: Called when the configuration
 *                            operation is done.
 */
Socket.prototype.setMulticastLoopback = function (flag, callback) {
  var self = this
  if (!callback) callback = function () {}
  if (self._bindState === BIND_STATE_BOUND) {
    setMulticastLoopback(callback)
  } else {
    self._bindTasks.push({
      fn: setMulticastLoopback,
      callback: callback
    })
  }

  function setMulticastLoopback (callback) {
    chrome.sockets.udp.setMulticastLoopbackMode(self.id, flag, callback)
  }
}

/**
 * Tells the kernel to join a multicast group with IP_ADD_MEMBERSHIP socket
 * option.
 *
 * If multicastInterface is not specified, the OS will try to add membership
 * to all valid interfaces.
 *
 * NOTE: The Chrome version of this function is async, whereas the node
 * version is sync. Keep this in mind.
 *
 * @param {string} multicastAddress
 * @param {string} [multicastInterface] Optional
 * @param {function} callback CHROME-SPECIFIC: Called when the configuration
 *                            operation is done.
 */
Socket.prototype.addMembership = function (multicastAddress,
  multicastInterface,
  callback) {
  var self = this
  if (!callback) callback = function () {}
  chrome.sockets.udp.joinGroup(self.id, multicastAddress, callback)
}

/**
 * Opposite of addMembership - tells the kernel to leave a multicast group
 * with IP_DROP_MEMBERSHIP socket option. This is automatically called by the
 * kernel when the socket is closed or process terminates, so most apps will
 * never need to call this.
 *
 * NOTE: The Chrome version of this function is async, whereas the node
 * version is sync. Keep this in mind.
 *
 * If multicastInterface is not specified, the OS will try to drop membership
 * to all valid interfaces.
 *
 * @param  {[type]} multicastAddress
 * @param  {[type]} multicastInterface Optional
 * @param {function} callback CHROME-SPECIFIC: Called when the configuration
 *                            operation is done.
 */
Socket.prototype.dropMembership = function (multicastAddress,
  multicastInterface,
  callback) {
  var self = this
  if (!callback) callback = function () {}
  chrome.sockets.udp.leaveGroup(self.id, multicastAddress, callback)
}

Socket.prototype.unref = function () {
  // No chrome.sockets equivalent
}

Socket.prototype.ref = function () {
  // No chrome.sockets equivalent
}
