/*!
 * upnp.js - upnp for bcoin
 * Copyright (c) 2017, Christopher Jeffrey (MIT License).
 * https://github.com/bcoin-org/bcoin
 */

'use strict';

var assert = require('assert');
var dgram = require('dgram');
var url = require('url');
var request = require('../http/request');
var co = require('../utils/co');
var Lock = require('../utils/lock');
var IP = require('../utils/ip');

/**
 * UPNP
 * @alias module:net.UPNP
 * @constructor
 * @param {String?} host - Multicast IP.
 * @param {Number?} port - Multicast port.
 * @param {String?} gateway - Gateway name.
 */

function UPNP(host, port, gateway) {
  if (!(this instanceof UPNP))
    return new UPNP(host, port, gateway);

  this.host = host || '239.255.255.250';
  this.port = port || 1900;
  this.gateway = gateway || UPNP.INTERNET_GATEWAY;
  this.locker = new Lock();
  this.timeout = null;
  this.job = null;
}

/**
 * Default internet gateway string.
 * @const {String}
 * @default
 */

UPNP.INTERNET_GATEWAY = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1';

/**
 * Default service types.
 * @const {String[]}
 * @default
 */

UPNP.WAN_SERVICES = [
  'urn:schemas-upnp-org:service:WANIPConnection:1',
  'urn:schemas-upnp-org:service:WANPPPConnection:1'
];

/**
 * Timeout before killing request.
 * @const {Number}
 * @default
 */

UPNP.RESPONSE_TIMEOUT = 3000;

/**
 * Clean up current job.
 * @private
 * @returns {Job}
 */

UPNP.prototype.cleanupJob = function cleanupJob() {
  var job = this.job;

  assert(this.socket);
  assert(this.job);

  this.job = null;

  this.socket.close();
  this.socket = null;

  this.stopTimeout();

  return job;
};

/**
 * Reject current job.
 * @private
 * @param {Error} err
 */

UPNP.prototype.rejectJob = function rejectJob(err) {
  var job = this.cleanupJob();
  job.reject(err);
};

/**
 * Resolve current job.
 * @private
 * @param {Object} result
 */

UPNP.prototype.resolveJob = function resolveJob(result) {
  var job = this.cleanupJob();
  job.resolve(result);
};

/**
 * Start gateway timeout.
 * @private
 */

UPNP.prototype.startTimeout = function startTimeout() {
  var self = this;
  this.stopTimeout();
  this.timeout = setTimeout(function() {
    self.timeout = null;
    self.rejectJob(new Error('Request timed out.'));
  }, UPNP.RESPONSE_TIMEOUT);
};

/**
 * Stop gateway timeout.
 * @private
 */

UPNP.prototype.stopTimeout = function stopTimeout() {
  if (this.timeout != null) {
    clearTimeout(this.timeout);
    this.timeout = null;
  }
};

/**
 * Discover gateway.
 * @returns {Promise} Location string.
 */

UPNP.prototype.discover = co(function* discover() {
  var unlock = yield this.locker.lock();
  try {
    return yield this._discover();
  } finally {
    unlock();
  }
});

/**
 * Discover gateway (without a lock).
 * @private
 * @returns {Promise} Location string.
 */

UPNP.prototype._discover = co(function* discover() {
  var self = this;
  var socket, msg;

  socket = dgram.createSocket('udp4');

  socket.on('error', function(err) {
    self.rejectJob(err);
  });

  socket.on('message', function(data, rinfo) {
    var msg = data.toString('utf8');
    self.handleMsg(msg);
  });

  this.socket = socket;
  this.startTimeout();

  msg = ''
    + 'M-SEARCH * HTTP/1.1\r\n'
    + 'HOST: ' + this.host + ':' + this.port + '\r\n'
    + 'MAN: ssdp:discover\r\n'
    + 'MX: 10\r\n'
    + 'ST: ssdp:all\r\n';

  socket.send(msg, this.port, this.host);

  return yield new Promise(function(resolve, reject) {
    self.job = co.job(resolve, reject);
  });
});

/**
 * Handle incoming UDP message.
 * @private
 * @param {String} msg
 * @returns {Promise}
 */

UPNP.prototype.handleMsg = co(function* handleMsg(msg) {
  var headers;

  if (!this.socket)
    return;

  try {
    headers = UPNP.parseHeader(msg);
  } catch (e) {
    return;
  }

  if (!headers.location)
    return;

  if (headers.st !== this.gateway)
    return;

  this.resolveJob(headers.location);
});

/**
 * Resolve service parameters from location.
 * @param {String} location
 * @param {String[]} targets - Target services.
 * @returns {Promise}
 */

UPNP.prototype.resolve = co(function* resolve(location, targets) {
  var host = parseHost(location);
  var res, xml, services, service;

  if (!targets)
    targets = UPNP.WAN_SERVICES;

  res = yield request({
    method: 'GET',
    uri: location,
    timeout: UPNP.RESPONSE_TIMEOUT,
    expect: 'xml'
  });

  xml = XMLElement.fromRaw(res.body);

  services = parseServices(xml);
  assert(services.length > 0, 'No services found.');

  service = extractServices(services, targets);
  assert(service, 'No service found.');
  assert(service.serviceId, 'No service ID found.');
  assert(service.serviceId.length > 0, 'No service ID found.');
  assert(service.controlURL, 'No control URL found.');
  assert(service.controlURL.length > 0, 'No control URL found.');

  service.controlURL = prependHost(host, service.controlURL);

  if (service.eventSubURL)
    service.eventSubURL = prependHost(host, service.eventSubURL);

  if (service.SCPDURL)
    service.SCPDURL = prependHost(host, service.SCPDURL);

  return service;
});

/**
 * Parse UPNP datagram.
 * @private
 * @param {String} str
 * @returns {Object}
 */

UPNP.parseHeader = function parseHeader(str) {
  var lines = str.split(/\r?\n/);
  var headers = {};
  var i, line, index, left, right;

  for (i = 0; i < lines.length; i++) {
    line = lines[i];

    line = line.trim();

    if (line.length === 0)
      continue;

    index = line.indexOf(':');

    if (index === -1) {
      left = line.toLowerCase();
      headers[left] = '';
      continue;
    }

    left = line.substring(0, index);
    right = line.substring(index + 1);

    left = left.trim();
    right = right.trim();

    left = left.toLowerCase();

    headers[left] = right;
  }

  return headers;
};

/**
 * Discover gateway and resolve service.
 * @param {String?} host - Multicast IP.
 * @param {Number?} port - Multicast port.
 * @param {String?} gateway - Gateway type.
 * @param {String[]?} targets - Target service types.
 * @returns {Promise} Service.
 */

UPNP.discover = co(function* discover(host, port, gateway, targets) {
  var upnp = new UPNP(host, port, gateway);
  var location = yield upnp.discover();
  var service = yield upnp.resolve(location, targets);
  return new UPNPService(service);
});

/**
 * Gateway Service
 * @constructor
 * @ignore
 * @param {Object} options - Service parameters.
 */

function UPNPService(options) {
  if (!(this instanceof UPNPService))
    return new UPNPService(options);

  this.serviceType = options.serviceType;
  this.serviceId = options.serviceId;
  this.controlURL = options.controlURL;
  this.eventSubURL = options.eventSubURL;
  this.SCPDURL = options.SCPDURL;
}

/**
 * Compile SOAP request.
 * @private
 * @param {String} action
 * @param {String[]} args
 * @returns {String}
 */

UPNPService.prototype.createRequest = function createRequest(action, args) {
  var params = '';
  var i, arg;

  for (i = 0; i < args.length; i++) {
    arg = args[i];
    params += '<' + arg[0]+ '>';
    if (arg.length > 1)
      params += arg[1];
    params += '</' + arg[0] + '>';
  }

  return ''
    + '<?xml version="1.0"?>'
    + '<s:Envelope '
    + 'xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
    + 's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
    + '<s:Body>'
    + '<u:' + action + ' xmlns:u='
    + JSON.stringify(this.serviceType) + '>'
    + params
    + '</u:' + action + '>'
    + '</s:Body>'
    + '</s:Envelope>';
};

/**
 * Send SOAP request and parse XML response.
 * @private
 * @param {String} action
 * @param {String[]} args
 * @returns {XMLElement}
 */

UPNPService.prototype.soapRequest = co(function* soapRequest(action, args) {
  var req = this.createRequest(action, args);
  var res, xml, err;

  res = yield request({
    method: 'POST',
    uri: this.controlURL,
    timeout: UPNP.RESPONSE_TIMEOUT,
    expect: 'xml',
    headers: {
      'Content-Type': 'text/xml; charset="utf-8"',
      'Content-Length': Buffer.byteLength(req, 'utf8') + '',
      'Connection': 'close',
      'SOAPAction': JSON.stringify(this.serviceType + '#' + action)
    },
    body: req
  });

  xml = XMLElement.fromRaw(res.body);
  err = findError(xml);

  if (err)
    throw err;

  return xml;
});

/**
 * Attempt to get external IP from service (wan).
 * @returns {Promise}
 */

UPNPService.prototype.getExternalIP = co(function* getExternalIP() {
  var action = 'GetExternalIPAddress';
  var xml = yield this.soapRequest(action, []);
  var ip = findIP(xml);

  if (!ip)
    throw new Error('Could not find external IP.');

  return ip;
});

/**
 * Attempt to add port mapping to local IP.
 * @param {String} remote - Remote IP.
 * @param {Number} src - Remote port.
 * @param {Number} dest - Local port.
 * @returns {Promise}
 */

UPNPService.prototype.addPortMapping = co(function* addPortMapping(remote, src, dest) {
  var action = 'AddPortMapping';
  var local = IP.getPrivate();
  var xml, child;

  if (!local)
    throw new Error('Cannot determine local IP.');

  xml = yield this.soapRequest(action, [
    ['NewRemoteHost', remote],
    ['NewExternalPort', src],
    ['NewProtocol', 'TCP'],
    ['NewInternalClient', local],
    ['NewInternalPort', dest],
    ['NewEnabled', 'True'],
    ['NewPortMappingDescription', 'upnp:bcoin'],
    ['NewLeaseDuration', 0]
  ]);

  child = xml.find('AddPortMappingResponse');

  if (!child)
    throw new Error('Port mapping failed.');

  return child.text;
});

/**
 * Attempt to remove port mapping from local IP.
 * @param {String} remote - Remote IP.
 * @param {Number} port - Remote port.
 * @returns {Promise}
 */

UPNPService.prototype.removePortMapping = co(function* removePortMapping(remote, port) {
  var action = 'DeletePortMapping';
  var xml, child;

  xml = yield this.soapRequest(action, [
    ['NewRemoteHost', remote],
    ['NewExternalPort', port],
    ['NewProtocol', 'TCP']
  ]);

  child = xml.find('DeletePortMappingResponse');

  if (!child)
    throw new Error('Port unmapping failed.');

  return child.text;
});

/**
 * XML Element
 * @constructor
 * @ignore
 */

function XMLElement(name) {
  this.name = name;
  this.type = name.replace(/^[^:]:/, '');
  this.children = [];
  this.text = '';
}

/**
 * Insantiate element from raw XML.
 * @param {String} xml
 * @returns {XMLElement}
 */

XMLElement.fromRaw = function fromRaw(xml) {
  var sentinel = new XMLElement('');
  var current = sentinel;
  var stack = [];
  var decl = false;
  var m, element, name, text, trailing;

  stack.push(sentinel);

  while (xml.length) {
    if (m = /^<\?xml[^<>]*\?>/i.exec(xml)) {
      xml = xml.substring(m[0].length);
      assert(current === sentinel, 'XML declaration inside element.');
      assert(!decl, 'XML declaration seen twice.');
      decl = true;
      continue;
    }

    if (m = /^<([\w:]+)[^<>]*?(\/?)>/i.exec(xml)) {
      xml = xml.substring(m[0].length);
      name = m[1];
      trailing = m[2] === '/';
      element = new XMLElement(name);

      if (trailing) {
        current.add(element);
        continue;
      }

      stack.push(element);
      current.add(element);
      current = element;

      continue;
    }

    if (m = /^<\/([\w:]+)[^<>]*>/i.exec(xml)) {
      xml = xml.substring(m[0].length);
      name = m[1];
      assert(stack.length !== 1, 'No start tag.');
      element = stack.pop();
      assert(element.name === name, 'Tag mismatch.');
      current = stack[stack.length - 1];
      if (current === sentinel)
        break;
      continue;
    }

    if (m = /^([^<]+)/i.exec(xml)) {
      xml = xml.substring(m[0].length);
      text = m[1];
      current.text = text.trim();
      continue;
    }

    throw new Error('XML parse error.');
  }

  assert(sentinel.children.length > 0, 'No root element.');

  return sentinel.children[0];
};

/**
 * Push element onto children.
 * @param {XMLElement} child
 * @returns {Number}
 */

XMLElement.prototype.add = function add(child) {
  return this.children.push(child);
};

/**
 * Collect all descendants with matching name.
 * @param {String} name
 * @returns {XMLElement[]}
 */

XMLElement.prototype.collect = function collect(name) {
  return this._collect(name, []);
};

/**
 * Collect all descendants with matching name.
 * @private
 * @param {String} name
 * @param {XMLElement[]} result
 * @returns {XMLElement[]}
 */

XMLElement.prototype._collect = function _collect(name, result) {
  var i, child;

  for (i = 0; i < this.children.length; i++) {
    child = this.children[i];

    if (child.type === name) {
      result.push(child);
      continue;
    }

    child._collect(name, result);
  }

  return result;
};

/**
 * Find child element with matching name.
 * @param {String} name
 * @returns {XMLElement|null}
 */

XMLElement.prototype.find = function find(name) {
  var i, child;

  for (i = 0; i < this.children.length; i++) {
    child = this.children[i];

    if (child.type === name)
      return child;

    child = child.find(name);

    if (child)
      return child;
  }
};

/*
 * XML Helpers
 */

function parseServices(el) {
  var children = el.collect('service');
  var services = [];
  var i, child;

  for (i = 0; i < children.length; i++) {
    child = children[i];
    services.push(parseService(children[i]));
  }

  return services;
}

function parseService(el) {
  var service = {};
  var i, child;

  for (i = 0; i < el.children.length; i++) {
    child = el.children[i];

    if (child.children.length > 0)
      continue;

    service[child.type] = child.text;
  }

  return service;
}

function findService(services, name) {
  var i, service;

  for (i = 0; i < services.length; i++) {
    service = services[i];
    if (service.serviceType === name)
      return service;
  }
}

function extractServices(services, targets) {
  var i, name, service;

  for (i = 0; i < targets.length; i++) {
    name = targets[i];
    service = findService(services, name);
    if (service)
      return service;
  }
}

function findIP(el) {
  var child = el.find('NewExternalIPAddress');

  if (!child)
    return;

  return IP.normalize(child.text);
}

function findError(el) {
  var child = el.find('UPnPError');
  var code = -1;
  var desc = 'Unknown';
  var ccode, cdesc;

  if (!child)
    return;

  ccode = child.find('errorCode');
  cdesc = child.find('errorDescription');

  if (ccode && /^\d+$/.test(ccode.text))
    code = +ccode.text;

  if (cdesc)
    desc = cdesc.text;

  return new Error('UPnPError: ' + desc + ' (' + code + ')');
}

/*
 * Helpers
 */

function parseHost(uri) {
  var data = url.parse(uri);

  assert(data.protocol === 'http:' || data.protocol === 'https:',
    'Bad URL for location.');

  return data.protocol + '//' + data.host;
}

function prependHost(host, uri) {
  if (uri.indexOf('://') === -1) {
    if (uri[0] !== '/')
      uri = '/' + uri;
    uri = host + uri;
  }
  return uri;
}

/*
 * Expose
 */

module.exports = UPNP;
