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

'use strict';

/**
 * @module net/socks
 */

var assert = require('assert');
var EventEmitter = require('events').EventEmitter;
var net = require('net');
var util = require('../utils/util');
var co = require('../utils/co');
var IP = require('../utils/ip');
var StaticWriter = require('../utils/staticwriter');
var BufferReader = require('../utils/reader');

/**
 * SOCKS state machine
 * @constructor
 */

function SOCKS() {
  if (!(this instanceof SOCKS))
    return new SOCKS();

  EventEmitter.call(this);

  this.socket = new net.Socket();
  this.state = SOCKS.states.INIT;
  this.target = SOCKS.states.INIT;
  this.destHost = '0.0.0.0';
  this.destPort = 0;
  this.username = '';
  this.password = '';
  this.name = 'localhost';
  this.destroyed = false;
  this.timeout = null;
  this.proxied = false;
}

util.inherits(SOCKS, EventEmitter);

SOCKS.states = {
  INIT: 0,
  CONNECT: 1,
  HANDSHAKE: 2,
  AUTH: 3,
  PROXY: 4,
  PROXY_DONE: 5,
  RESOLVE: 6,
  RESOLVE_DONE: 7
};

SOCKS.statesByVal = util.revMap(SOCKS.states);

SOCKS.errors = [
  '',
  'General failure',
  'Connection not allowed',
  'Network is unreachable',
  'Host is unreachable',
  'Connection refused',
  'TTL expired',
  'Command not supported',
  'Address type not supported',
  'Unknown proxy error'
];

SOCKS.prototype.error = function error(err) {
  var msg;

  if (this.destroyed)
    return;

  if (err instanceof Error) {
    this.emit('error', err);
    this.destroy();
    return;
  }

  msg = util.fmt.apply(util, arguments);
  this.emit('error', new Error(msg));
  this.destroy();
};

SOCKS.prototype.getError = function getError(code) {
  if (code >= SOCKS.errors.length)
    return SOCKS.errors[9];

  return SOCKS.errors[code];
};

SOCKS.prototype.destroy = function destroy() {
  if (this.destroyed)
    return;

  this.destroyed = true;
  this.socket.destroy();

  this.stopTimeout();

  if (this.state === this.target)
    return;

  this.emit('close');
};

SOCKS.prototype.startTimeout = function startTimeout() {
  var self = this;
  this.timeout = setTimeout(function() {
    var state = SOCKS.statesByVal[self.state];
    self.timeout = null;
    self.error('SOCKS request timed out (state=%s).', state);
  }, 8000);
};

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

SOCKS.prototype.connect = function connect(port, host) {
  var self = this;

  assert(typeof port === 'number');
  assert(typeof host === 'string');

  this.state = SOCKS.states.CONNECT;
  this.socket.connect(port, host);

  this.socket.on('connect', function() {
    if (self.proxied)
      return;
    self.handleConnect();
  });

  this.socket.on('data', function(data) {
    if (self.proxied)
      return;
    self.handleData(data);
  });

  this.socket.on('error', function(err) {
    if (self.proxied)
      return;
    self.handleError(err);
  });

  this.socket.on('close', function() {
    if (self.proxied)
      return;
    self.handleClose();
  });
};

SOCKS.prototype.open = function open(options) {
  assert(this.state === SOCKS.states.INIT);

  assert(options);

  if (options.username != null) {
    assert(typeof options.username === 'string');
    this.username = options.username;
    assert(typeof options.password === 'string',
      'Username must have a password.');
  }

  if (options.password != null) {
    assert(typeof options.password === 'string');
    this.password = options.password;
  }

  this.startTimeout();
  this.connect(options.port, options.host);
};

SOCKS.prototype.proxy = function proxy(options) {
  assert(options);
  assert(typeof options.destHost === 'string');
  assert(typeof options.destPort === 'number');

  this.destHost = options.destHost;
  this.destPort = options.destPort;
  this.target = SOCKS.states.PROXY_DONE;

  this.open(options);
};

SOCKS.prototype.resolve = function resolve(options) {
  assert(options);
  assert(typeof options.name === 'string');

  this.name = options.name;
  this.target = SOCKS.states.RESOLVE_DONE;

  this.open(options);
};

SOCKS.prototype.handleConnect = function handleConnect() {
  assert(this.state === SOCKS.states.CONNECT);
  this.sendHandshake();
};

SOCKS.prototype.handleError = function handleError(err) {
  this.error(err);
};

SOCKS.prototype.handleClose = function handleClose() {
  var state;

  if (this.state !== this.target) {
    state = SOCKS.statesByVal[this.state];
    this.error('SOCKS request destroyed (state=%s).', state);
    return;
  }

  this.destroy();
};

SOCKS.prototype.handleData = function handleData(data) {
  switch (this.state) {
    case SOCKS.states.INIT:
      this.error('Data before SOCKS connection.');
      break;
    case SOCKS.states.CONNECT:
      this.error('Data before SOCKS handshake.');
      break;
    case SOCKS.states.HANDSHAKE:
      this.handleHandshake(data);
      break;
    case SOCKS.states.AUTH:
      this.handleAuth(data);
      break;
    case SOCKS.states.PROXY:
      this.handleProxy(data);
      break;
    case SOCKS.states.RESOLVE:
      this.handleResolve(data);
      break;
    case SOCKS.states.PROXY_DONE:
    case SOCKS.states.RESOLVE_DONE:
      break;
    default:
      assert(false, 'Bad state.');
      break;
  }
};

SOCKS.prototype.sendHandshake = function sendHandshake() {
  var packet;

  if (this.username) {
    packet = new Buffer(4);
    packet[0] = 0x05;
    packet[1] = 0x02;
    packet[2] = 0x00;
    packet[3] = 0x02;
  } else {
    packet = new Buffer(3);
    packet[0] = 0x05;
    packet[1] = 0x01;
    packet[2] = 0x00;
  }

  this.state = SOCKS.states.HANDSHAKE;
  this.socket.write(packet);
};

SOCKS.prototype.handleHandshake = function handleHandshake(data) {
  if (data.length !== 2) {
    this.error('Bad SOCKS handshake response (size).');
    return;
  }

  if (data[0] !== 0x05) {
    this.error('Bad SOCKS version for handshake.');
    return;
  }

  this.emit('handshake');

  switch (data[1]) {
    case 0xff:
      this.error('No acceptable SOCKS auth methods.');
      break;
    case 0x02:
      this.sendAuth();
      break;
    case 0x00:
      this.state = SOCKS.states.AUTH;
      this.auth();
      break;
    default:
      this.error('SOCKS handshake error: %d.', data[1]);
      break;
  }
};

SOCKS.prototype.sendAuth = function sendAuth() {
  var user = this.username;
  var pass = this.password;
  var ulen, plen, size, packet;

  if (!user) {
    this.error('No username passed for SOCKS auth.');
    return;
  }

  if (!pass) {
    this.error('No password passed for SOCKS auth.');
    return;
  }

  ulen = Buffer.byteLength(user, 'ascii');
  plen = Buffer.byteLength(pass, 'ascii');
  size = 3 + ulen + plen;

  packet = new StaticWriter(size);
  packet.writeU8(0x01);
  packet.writeU8(ulen);
  packet.writeString(user, 'ascii');
  packet.writeU8(plen);
  packet.writeString(pass, 'ascii');
  packet = packet.render();

  this.state = SOCKS.states.AUTH;
  this.socket.write(packet);
};

SOCKS.prototype.handleAuth = function handleAuth(data) {
  if (data.length !== 2) {
    this.error('Bad packet size for SOCKS auth.');
    return;
  }

  if (data[0] !== 0x01) {
    this.error('Bad SOCKS auth version number.');
    return;
  }

  if (data[1] !== 0x00) {
    this.error('SOCKS auth failure: %d.', data[0]);
    return;
  }

  this.auth();
};

SOCKS.prototype.auth = function auth() {
  this.emit('auth');

  switch (this.target) {
    case SOCKS.states.PROXY_DONE:
      this.sendProxy();
      break;
    case SOCKS.states.RESOLVE_DONE:
      this.sendResolve();
      break;
    default:
      this.error('Bad target state.');
      break;
  }
};

SOCKS.prototype.sendProxy = function sendProxy() {
  var host = this.destHost;
  var port = this.destPort;
  var ip, len, type, name, packet;

  switch (IP.getStringType(host)) {
    case IP.types.IPV4:
      ip = IP.toBuffer(host);
      type = 0x01;
      name = ip.slice(12, 16);
      len = 4;
      break;
    case IP.types.IPV6:
      ip = IP.toBuffer(host);
      type = 0x04;
      name = ip;
      len = 16;
      break;
    default:
      type = 0x03;
      name = new Buffer(host, 'ascii');
      len = 1 + name.length;
      break;
  }

  packet = new StaticWriter(6 + len);

  packet.writeU8(0x05);
  packet.writeU8(0x01);
  packet.writeU8(0x00);
  packet.writeU8(type);

  if (type === 0x03)
    packet.writeU8(name.length);

  packet.writeBytes(name);
  packet.writeU16BE(port);

  packet = packet.render();

  this.state = SOCKS.states.PROXY;
  this.socket.write(packet);
};

SOCKS.prototype.handleProxy = function handleProxy(data) {
  var addr, msg;

  if (data.length < 6) {
    this.error('Bad packet size for SOCKS connect.');
    return;
  }

  if (data[0] !== 0x05) {
    this.error('Bad SOCKS version for connect.');
    return;
  }

  if (data[1] !== 0x00) {
    msg = this.getError(data[1]);
    this.error('SOCKS connect error: %s.', msg);
    return;
  }

  if (data[2] !== 0x00) {
    this.error('SOCKS connect failed (padding).');
    return;
  }

  try {
    addr = parseAddr(data, 3);
  } catch (e) {
    this.error(e);
    return;
  }

  this.state = SOCKS.states.PROXY_DONE;
  this.stopTimeout();
  this.proxied = true;

  this.emit('proxy address', addr);
  this.emit('proxy', this.socket);
};

SOCKS.prototype.sendResolve = function sendResolve() {
  var name = this.name;
  var len = Buffer.byteLength(name, 'utf8');
  var packet = new StaticWriter(7 + len);

  packet.writeU8(0x05);
  packet.writeU8(0xf0);
  packet.writeU8(0x00);
  packet.writeU8(0x03);
  packet.writeU8(len);
  packet.writeString(name, 'utf8');
  packet.writeU16BE(0);
  packet = packet.render();

  this.state = SOCKS.states.RESOLVE;
  this.socket.write(packet);
};

SOCKS.prototype.handleResolve = function handleResolve(data) {
  var addr, msg;

  if (data.length < 6) {
    this.error('Bad packet size for tor resolve.');
    return;
  }

  if (data[0] !== 0x05) {
    this.error('Bad SOCKS version for tor resolve.');
    return;
  }

  if (data[1] !== 0x00) {
    msg = this.getError(data[1]);
    this.error('Tor resolve error: %s (%s).', msg, this.name);
    return;
  }

  if (data[2] !== 0x00) {
    this.error('Tor resolve failed (padding).');
    return;
  }

  try {
    addr = parseAddr(data, 3);
  } catch (e) {
    this.error(e);
    return;
  }

  if (addr.type === 0x03) {
    this.error('Bad address type for tor resolve.');
    return;
  }

  this.state = SOCKS.states.RESOLVE_DONE;
  this.destroy();

  this.emit('resolve', [addr.host]);
};

SOCKS.resolve = function resolve(options) {
  var socks = new SOCKS();
  return new Promise(function(resolve, reject) {
    socks.resolve(options);
    socks.on('resolve', resolve);
    socks.on('error', reject);
  });
};

SOCKS.proxy = function proxy(options) {
  var socks = new SOCKS();
  return new Promise(function(resolve, reject) {
    socks.proxy(options);
    socks.on('proxy', resolve);
    socks.on('error', reject);
  });
};

/**
 * Proxy Socket
 * @constructor
 * @param {String} host
 * @param {Number} port
 * @param {String?} user
 * @param {String?} pass
 */

function Proxy(host, port, user, pass) {
  if (!(this instanceof Proxy))
    return new Proxy(host, port, user, pass);

  EventEmitter.call(this);

  assert(typeof host === 'string');
  assert(typeof port === 'number');

  this.socket = null;
  this.host = host;
  this.port = port;
  this.username = user || null;
  this.password = pass || null;
  this.bytesWritten = 0;
  this.bytesRead = 0;
  this.remoteAddress = null;
  this.remotePort = 0;
  this.ops = [];
}

util.inherits(Proxy, EventEmitter);

Proxy.prototype.connect = co(function* connect(port, host) {
  var self = this;
  var i, options, socket;

  assert(!this.socket, 'Already connected.');

  options = {
    host: this.host,
    port: this.port,
    username: this.username,
    password: this.password,
    destHost: host,
    destPort: port
  };

  try {
    socket = yield SOCKS.proxy(options);
  } catch (e) {
    this.emit('error', e);
    return;
  }

  this.remoteAddress = host;
  this.remotePort = port;
  this.socket = socket;

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

  this.socket.on('close', function() {
    self.emit('close');
  });

  this.socket.on('data', function(data) {
    self.bytesRead += data.length;
    self.emit('data', data);
  });

  this.socket.on('drain', function() {
    self.emit('drain');
  });

  this.socket.on('timeout', function() {
    self.emit('timeout');
  });

  for (i = 0; i < this.ops.length; i++)
    this.ops[i].call(this);

  this.ops.length = 0;

  this.emit('connect');
});

Proxy.prototype.setKeepAlive = function setKeepAlive(enable, delay) {
  if (!this.socket) {
    this.ops.push(function() {
      this.socket.setKeepAlive(enable, delay);
    });
    return;
  }
  this.socket.setKeepAlive(enable, delay);
};

Proxy.prototype.setNoDelay = function setNoDelay(enable) {
  if (!this.socket) {
    this.ops.push(function() {
      this.socket.setNoDelay(enable);
    });
    return;
  }
  this.socket.setNoDelay(enable);
};

Proxy.prototype.setTimeout = function setTimeout(timeout, callback) {
  if (!this.socket) {
    this.ops.push(function() {
      this.socket.setTimeout(timeout, callback);
    });
    return;
  }
  this.socket.setTimeout(timeout, callback);
};

Proxy.prototype.write = function write(data, callback) {
  assert(this.socket, 'Not connected.');
  this.bytesWritten += data.length;
  return this.socket.write(data, callback);
};

Proxy.prototype.end = function end() {
  assert(this.socket, 'Not connected.');
  return this.socket.end();
};

Proxy.prototype.pause = function pause() {
  assert(this.socket, 'Not connected.');
  return this.socket.pause();
};

Proxy.prototype.resume = function resume() {
  assert(this.socket, 'Not connected.');
  return this.socket.resume();
};

Proxy.prototype.destroy = function destroy() {
  if (!this.socket)
    return;
  return this.socket.destroy();
};

/*
 * Helpers
 */

function parseProxy(host) {
  var index = host.indexOf('@');
  var addr, left, right, parts;

  if (index === -1) {
    addr = IP.fromHostname(host, 1080);
    return {
      host: addr.host,
      port: addr.port
    };
  }

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

  parts = left.split(':');
  assert(parts.length > 1, 'Bad username and password.');

  addr = IP.fromHostname(right, 1080);

  return {
    host: addr.host,
    port: addr.port,
    username: parts[0],
    password: parts[1]
  };
}

function parseAddr(data, offset) {
  var br = new BufferReader(data);
  var type, len, host, port;

  if (br.left() < offset + 2)
    throw new Error('Bad SOCKS address length.');

  br.seek(offset);

  type = br.readU8();

  switch (type) {
    case 0x01:
      if (br.left() < 6)
        throw new Error('Bad SOCKS ipv4 length.');

      host = IP.toString(br.readBytes(4));
      port = br.readU16BE();
      break;
    case 0x03:
      len = br.readU8();

      if (br.left() < len + 2)
        throw new Error('Bad SOCKS domain length.');

      host = br.readString(len, 'utf8');
      port = br.readU16BE();
      break;
    case 0x04:
      if (br.left() < 18)
        throw new Error('Bad SOCKS ipv6 length.');

      host = IP.toString(br.readBytes(16));
      port = br.readU16BE();
      break;
    default:
      throw new Error('Unknown SOCKS address type: ' + type + '.');
  }

  return {
    type: type,
    host: host,
    port: port
  };
}

/*
 * Expose
 */

exports.connect = function connect(proxy, destPort, destHost) {
  var addr = parseProxy(proxy);
  var host = addr.host;
  var port = addr.port;
  var user = addr.username;
  var pass = addr.password;
  var socket;

  socket = new Proxy(host, port, user, pass);
  socket.connect(destPort, destHost);

  return socket;
};

exports.resolve = function resolve(proxy, name) {
  var addr = parseProxy(proxy);
  return SOCKS.resolve({
    host: addr.host,
    port: addr.port,
    username: addr.username,
    password: addr.password,
    name: name
  });
};
