'use strict';
const util = require('util');
const EventEmitter = require('events').EventEmitter;
const parser = require('./parser');

const emit = EventEmitter.prototype.emit;
const on = EventEmitter.prototype.on;
const slice = Array.prototype.slice;

exports = module.exports = Socket;

/**
 * Bind `obj` to `fn`.
 *
 * @param {Object} obj
 * @param {Function|String} fn or string
 * @return {Function}
 */
function bind(obj, fn, ...args) {
  if (typeof fn === 'string') fn = obj[fn];
  if (typeof fn !== 'function') throw new Error('bind() requires a function');
  return function (..._args) {
    return fn.apply(obj, args.concat(slice.call(_args)));
  };
}

/**
 * Base event name for messaging.
 *
 * @api public
 */
exports.event = '$stream';

exports.events = ['error', 'newListener', 'removeListener'];

util.inherits(Socket, EventEmitter);

/**
 * Bidirectional stream socket which wraps Socket.IO.
 *
 * @param {socket.io#Socket} socket.io
 * @api public
 */
function Socket(sio, options) {
  if (!(this instanceof Socket)) {
    return new Socket(sio, options);
  }

  EventEmitter.call(this);

  options = options || {};

  this.sio = sio;
  this.forceBase64 = !!options.forceBase64;
  this.streams = {};
  this.encoder = new parser.Encoder();
  this.decoder = new parser.Decoder();

  const eventName = exports.event;
  sio.on(eventName, bind(this, emit));
  sio.on(`${eventName}-read`, bind(this, '_onread'));
  sio.on(`${eventName}-write`, bind(this, '_onwrite'));
  sio.on(`${eventName}-end`, bind(this, '_onend'));
  sio.on(`${eventName}-error`, bind(this, '_onerror'));
  sio.on('error', bind(this, emit, 'error'));
  sio.on('disconnect', bind(this, '_ondisconnect'));

  this.encoder.on('stream', bind(this, '_onencode'));
  this.decoder.on('stream', bind(this, '_ondecode'));
}

/**
 * Original emit function.
 *
 * @api private
 */
Socket.prototype.$emit = emit;

/**
 * Emits streams to this corresponding server/client.
 *
 * @return {Socket} self
 * @api public
 */
Socket.prototype.emit = function (type) {
  /* eslint-disable prefer-rest-params,prefer-spread */
  if (~exports.events.indexOf(type)) {
    return emit.apply(this, arguments);
  }
  this._stream.apply(this, arguments);
  return this;
};

Socket.prototype.on = function (type, listener) {
  if (~exports.events.indexOf(type)) {
    return on.apply(this, arguments);
  }

  this._onstream(type, listener);
  return this;
};

/**
 * Sends a new stream request.
 *
 * @param {String} event type
 * @api private
 */
Socket.prototype._stream = function (type) {
  const self = this;
  let args = slice.call(arguments, 1);
  const ack = args[args.length - 1];
  if (typeof ack === 'function') {
    args[args.length - 1] = function () {
      let _args = slice.call(arguments);
      _args = self.decoder.decode(_args);
      ack.apply(this, _args);
    };
  }

  args = this.encoder.encode(args);
  const sio = this.sio;
  sio.emit.apply(sio, [exports.event, type].concat(args));
};

/**
 * Notifies the read event.
 *
 * @api private
 */
Socket.prototype._read = function (id, size) {
  this.sio.emit(`${exports.event}-read`, id, size);
};

/**
 * Requests to write a chunk.
 *
 * @api private
 */
Socket.prototype._write = function (id, chunk, encoding, callback) {
  if (Buffer.isBuffer(chunk)) {
    if (this.forceBase64) {
      encoding = 'base64';
      chunk = chunk.toString(encoding);
    } else if (!global.Buffer) {
      // socket.io can't handle Buffer when using browserify.
      if (chunk.toArrayBuffer) {
        chunk = chunk.toArrayBuffer();
      } else {
        chunk = chunk.buffer;
      }
    }
  }
  this.sio.emit(`${exports.event}-write`, id, chunk, encoding, callback);
};

Socket.prototype._end = function (id) {
  this.sio.emit(`${exports.event}-end`, id);
};

Socket.prototype._error = function (id, err) {
  this.sio.emit(`${exports.event}-error`, id, err.message || err);
};

/**
 * Handles a new stream request.
 *
 * @param {String} event type
 * @param {Function} listener
 *
 * @api private
 */
Socket.prototype._onstream = function (type, listener) {
  if (typeof listener !== 'function') {
    throw new TypeError('listener must be a function');
  }

  function onstream() {
    const self = this;
    let args = slice.call(arguments);
    const ack = args[args.length - 1];
    if (typeof ack === 'function') {
      args[args.length - 1] = function () {
        let _args = slice.call(arguments);
        _args = self.encoder.encode(_args);
        ack.apply(this, _args);
      };
    }

    args = this.decoder.decode(args);
    listener.apply(this, args);
  }

  // for removeListener
  onstream.listener = listener;

  on.call(this, type, onstream);
};

Socket.prototype._onread = function (id, size) {
  const stream = this.streams[id];
  if (stream) {
    stream._onread(size);
  }
};

Socket.prototype._onwrite = function (id, chunk, encoding, callback) {
  const stream = this.streams[id];
  if (!stream) {
    callback(`invalid stream id: ${id}`);
    return;
  }

  if (global.ArrayBuffer && chunk instanceof ArrayBuffer) {
    // make sure that chunk is a buffer for stream
    chunk = new Buffer(new Uint8Array(chunk));
  }
  stream._onwrite(chunk, encoding, callback);
};

Socket.prototype._onend = function (id) {
  const stream = this.streams[id];
  if (!stream) {
    return;
  }

  stream._end();
};

Socket.prototype._onerror = function (id, message) {
  const stream = this.streams[id];
  if (!stream) {
    return;
  }

  const err = new Error(message);
  err.remote = true;
  stream.emit('error', err);
};

Socket.prototype._ondisconnect = function () {
  let stream;
  const keys = Object.keys(this.streams);
  for (const id of keys) {
    stream = this.streams[id];
    stream.destroy();

    // Close streams when the underlaying
    // socket.io connection is closed (regardless why)
    stream.emit('close');
    stream.emit('error', new Error('Connection aborted'));
  }
};

Socket.prototype._onencode = function (stream) {
  if (stream.socket || stream.destroyed) {
    throw new Error('stream has already been sent.');
  }

  const id = stream.id;
  if (this.streams[id]) {
    throw new Error(`Encoded stream already exists: ${id}`);
  }

  this.streams[id] = stream;
  stream.socket = this;
};

Socket.prototype._ondecode = function (stream) {
  const id = stream.id;
  if (this.streams[id]) {
    this._error(id, new Error(`Decoded stream already exists: ${id}`));
    return;
  }

  this.streams[id] = stream;
  stream.socket = this;
};

Socket.prototype.cleanup = function (id) {
  delete this.streams[id];
};
