'use strict';

var net = require('net');
var dns = require('dns');
var EventEmitter = require('events').EventEmitter;
var IOServer = require('socket.io');
var util = require('../lib/utils/util');
var crypto = require('../lib/crypto/crypto');
var IP = require('../lib/utils/ip');
var BufferWriter = require('../lib/utils/writer');

var NAME_REGEX = /^[a-z0-9\-\.]+?\.(?:be|me|org|com|net|ch|de)$/i;

var TARGET = new Buffer(
  '0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
  'hex');

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

  EventEmitter.call(this);

  if (!options)
    options = {};

  this.options = options;
  this.target = options.target || TARGET;
  this.pow = options.pow === true;
  this.ports = options.ports || [];
  this.io = new IOServer();
  this.sockets = new WeakMap();

  this._init();
}

util.inherits(WSProxy, EventEmitter);

WSProxy.prototype._init = function _init() {
  var self = this;

  this.io.on('error', function(err) {
    self.emit('error', err);
  });

  this.io.on('connection', function(ws) {
    self._handleSocket(ws);
  });
};

WSProxy.prototype._handleSocket = function _handleSocket(ws) {
  var self = this;
  var state = new SocketState(this, ws);

  // Use a weak map to avoid
  // mutating the websocket object.
  this.sockets.set(ws, state);

  ws.emit('info', state.toInfo());

  ws.on('error', function(err) {
    self.emit('error', err);
  });

  ws.on('tcp connect', function(port, host, nonce) {
    self._handleConnect(ws, port, host, nonce);
  });

  ws.on('dns resolve', function(name, record, callback) {
    self._handleResolve(ws, name, record, callback);
  });
};

WSProxy.prototype._handleResolve = function _handleResolve(ws, name, record, callback) {
  if (typeof name !== 'string') {
    ws.disconnect();
    return;
  }

  if (typeof record !== 'string') {
    ws.disconnect();
    return;
  }

  if (typeof callback !== 'function') {
    ws.disconnect();
    return;
  }

  if (record !== 'A' && record !== 'AAAA') {
    this.log('Client sent a bad record type: %s.', record);
    ws.disconnect();
    return;
  }

  if (!NAME_REGEX.test(name) || name.length > 200) {
    this.log('Client sent a bad domain: %s.', name);
    ws.disconnect();
    return;
  }

  dns.resolve(name, record, function(err, result) {
    if (err) {
      callback({ message: err.message, code: err.code });
      return;
    }
    callback(null, result);
  });
};

WSProxy.prototype._handleConnect = function _handleConnect(ws, port, host, nonce) {
  var self = this;
  var state = this.sockets.get(ws);
  var socket, pow, raw;

  if (state.socket) {
    this.log('Client is trying to reconnect (%s).', state.host);
    return;
  }

  if (!util.isNumber(port)
      || typeof host !== 'string'
      || host.length === 0) {
    this.log('Client gave bad arguments (%s).', state.host);
    ws.emit('tcp close');
    ws.disconnect();
    return;
  }

  if (this.pow) {
    if (!util.isNumber(nonce)) {
      this.log('Client did not solve proof of work (%s).', state.host);
      ws.emit('tcp close');
      ws.disconnect();
      return;
    }

    pow = new BufferWriter();
    pow.writeU32(nonce);
    pow.writeBytes(state.snonce);
    pow.writeU32(port);
    pow.writeString(host, 'ascii');
    pow = pow.render();

    if (util.cmp(crypto.hash256(pow), this.target) > 0) {
      this.log('Client did not solve proof of work (%s).', state.host);
      ws.emit('tcp close');
      ws.disconnect();
      return;
    }
  }

  try {
    raw = IP.toBuffer(host);
    host = IP.toString(raw);
  } catch (e) {
    this.log('Client gave a bad host: %s (%s).', host, state.host);
    ws.emit('tcp error', {
      message: 'EHOSTUNREACH',
      code: 'EHOSTUNREACH'
    });
    ws.disconnect();
    return;
  }

  if (!IP.isRoutable(raw) || IP.isOnion(raw)) {
    this.log(
      'Client is trying to connect to a bad ip: %s (%s).',
      host, state.host);
    ws.emit('tcp error', {
      message: 'ENETUNREACH',
      code: 'ENETUNREACH'
    });
    ws.disconnect();
    return;
  }

  if (this.ports.indexOf(port) === -1) {
    this.log('Client is connecting to non-whitelist port (%s).', state.host);
    ws.emit('tcp error', {
      message: 'ENETUNREACH',
      code: 'ENETUNREACH'
    });
    ws.disconnect();
    return;
  }

  try {
    socket = state.connect(port, host);
    this.log('Connecting to %s (%s).', state.remoteHost, state.host);
  } catch (e) {
    this.log(e.message);
    this.log('Closing %s (%s).', state.remoteHost, state.host);
    ws.emit('tcp error', {
      message: 'ENETUNREACH',
      code: 'ENETUNREACH'
    });
    ws.disconnect();
    return;
  }

  socket.on('connect', function() {
    ws.emit('tcp connect', socket.remoteAddress, socket.remotePort);
  });

  socket.on('data', function(data) {
    ws.emit('tcp data', data.toString('hex'));
  });

  socket.on('error', function(err) {
    ws.emit('tcp error', {
      message: err.message,
      code: err.code || null
    });
  });

  socket.on('timeout', function() {
    ws.emit('tcp timeout');
  });

  socket.on('close', function() {
    self.log('Closing %s (%s).', state.remoteHost, state.host);
    ws.emit('tcp close');
    ws.disconnect();
  });

  ws.on('tcp data', function(data) {
    if (typeof data !== 'string')
      return;
    socket.write(new Buffer(data, 'hex'));
  });

  ws.on('tcp keep alive', function(enable, delay) {
    socket.setKeepAlive(enable, delay);
  });

  ws.on('tcp no delay', function(enable) {
    socket.setNoDelay(enable);
  });

  ws.on('tcp set timeout', function(timeout) {
    socket.setTimeout(timeout);
  });

  ws.on('tcp pause', function() {
    socket.pause();
  });

  ws.on('tcp resume', function() {
    socket.resume();
  });

  ws.on('disconnect', function() {
    socket.destroy();
  });
};

WSProxy.prototype.log = function log() {
  process.stdout.write('wsproxy: ');
  console.log.apply(console, arguments);
};

WSProxy.prototype.attach = function attach(server) {
  this.io.attach(server);
};

function SocketState(server, socket) {
  this.pow = server.pow;
  this.target = server.target;
  this.snonce = util.nonce(true);
  this.socket = null;
  this.host = IP.normalize(socket.conn.remoteAddress);
  this.remoteHost = null;
}

SocketState.prototype.toInfo = function toInfo() {
  return {
    pow: this.pow,
    target: this.target.toString('hex'),
    snonce: this.snonce.toString('hex')
  };
};

SocketState.prototype.connect = function connect(port, host) {
  this.socket = net.connect(port, host);
  this.remoteHost = IP.toHostname(host, port);
  return this.socket;
};

module.exports = WSProxy;
