const stream = require('stream')
const util = require('util')
const debug = require('debug')('serialport/stream')

//  VALIDATION
const DATABITS = Object.freeze([5, 6, 7, 8])
const STOPBITS = Object.freeze([1, 1.5, 2])
const PARITY = Object.freeze(['none', 'even', 'mark', 'odd', 'space'])
const FLOWCONTROLS = Object.freeze(['xon', 'xoff', 'xany', 'rtscts'])

const defaultSettings = Object.freeze({
  autoOpen: true,
  endOnClose: false,
  baudRate: 9600,
  dataBits: 8,
  hupcl: true,
  lock: true,
  parity: 'none',
  rtscts: false,
  stopBits: 1,
  xany: false,
  xoff: false,
  xon: false,
  highWaterMark: 64 * 1024,
})

const defaultSetFlags = Object.freeze({
  brk: false,
  cts: false,
  dtr: true,
  dts: false,
  rts: true,
})

function allocNewReadPool(poolSize) {
  const pool = Buffer.allocUnsafe(poolSize)
  pool.used = 0
  return pool
}

/**
 * A callback called with an error or null.
 * @typedef {function} errorCallback
 * @param {?error} error
 */

/**
 * A callback called with an error or an object with the modem line values (cts, dsr, dcd).
 * @typedef {function} modemBitsCallback
 * @param {?error} error
 * @param {?object} status
 * @param {boolean} [status.cts=false]
 * @param {boolean} [status.dsr=false]
 * @param {boolean} [status.dcd=false]
 */

/**
 * @typedef {Object} openOptions
 * @property {boolean} [autoOpen=true] Automatically opens the port on `nextTick`.
 * @property {number=} [baudRate=9600] The baud rate of the port to be opened. This should match one of the commonly available baud rates, such as 110, 300, 1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, or 115200. Custom rates are supported best effort per platform. The device connected to the serial port is not guaranteed to support the requested baud rate, even if the port itself supports that baud rate.
 * @property {number} [dataBits=8] Must be one of these: 8, 7, 6, or 5.
 * @property {number} [highWaterMark=65536] The size of the read and write buffers defaults to 64k.
 * @property {boolean} [lock=true] Prevent other processes from opening the port. Windows does not currently support `false`.
 * @property {number} [stopBits=1] Must be one of these: 1 or 2.
 * @property {string} [parity=none] Must be one of these: 'none', 'even', 'mark', 'odd', 'space'.
 * @property {boolean} [rtscts=false] flow control setting
 * @property {boolean} [xon=false] flow control setting
 * @property {boolean} [xoff=false] flow control setting
 * @property {boolean} [xany=false] flow control setting
 * @property {object=} bindingOptions sets binding-specific options
 * @property {Binding=} binding The hardware access binding. `Bindings` are how Node-Serialport talks to the underlying system. By default we auto detect Windows (`WindowsBinding`), Linux (`LinuxBinding`) and OS X (`DarwinBinding`) and load the appropriate module for your system.
 * @property {number} [bindingOptions.vmin=1] see [`man termios`](http://linux.die.net/man/3/termios) LinuxBinding and DarwinBinding
 * @property {number} [bindingOptions.vtime=0] see [`man termios`](http://linux.die.net/man/3/termios) LinuxBinding and DarwinBinding
 */

/**
 * Create a new serial port object for the `path`. In the case of invalid arguments or invalid options, when constructing a new SerialPort it will throw an error. The port will open automatically by default, which is the equivalent of calling `port.open(openCallback)` in the next tick. You can disable this by setting the option `autoOpen` to `false`.
 * @class SerialPort
 * @param {string} path - The system path of the serial port you want to open. For example, `/dev/tty.XXX` on Mac/Linux, or `COM1` on Windows.
 * @param {openOptions=} options - Port configuration options
 * @param {errorCallback=} openCallback - Called after a connection is opened. If this is not provided and an error occurs, it will be emitted on the port's `error` event. The callback will NOT be called if `autoOpen` is set to `false` in the `openOptions` as the open will not be performed.
 * @property {number} baudRate The port's baudRate. Use `.update` to change it. Read-only.
 * @property {object} binding The binding object backing the port. Read-only.
 * @property {boolean} isOpen `true` if the port is open, `false` otherwise. Read-only. (`since 5.0.0`)
 * @property {string} path The system path or name of the serial port. Read-only.
 * @throws {TypeError} When given invalid arguments, a `TypeError` will be thrown.
 * @emits open
 * @emits data
 * @emits close
 * @emits error
 * @alias module:serialport
 */
function SerialPort(path, options, openCallback) {
  if (!(this instanceof SerialPort)) {
    return new SerialPort(path, options, openCallback)
  }

  if (options instanceof Function) {
    openCallback = options
    options = {}
  }

  const settings = { ...defaultSettings, ...options }

  stream.Duplex.call(this, {
    highWaterMark: settings.highWaterMark,
  })

  const Binding = settings.binding || SerialPort.Binding

  if (!Binding) {
    throw new TypeError('"Bindings" is invalid pass it as `options.binding` or set it on `SerialPort.Binding`')
  }

  if (!path) {
    throw new TypeError(`"path" is not defined: ${path}`)
  }

  if (settings.baudrate) {
    throw new TypeError(`"baudrate" is an unknown option, did you mean "baudRate"?`)
  }

  if (typeof settings.baudRate !== 'number') {
    throw new TypeError(`"baudRate" must be a number: ${settings.baudRate}`)
  }

  if (DATABITS.indexOf(settings.dataBits) === -1) {
    throw new TypeError(`"databits" is invalid: ${settings.dataBits}`)
  }

  if (STOPBITS.indexOf(settings.stopBits) === -1) {
    throw new TypeError(`"stopbits" is invalid: ${settings.stopbits}`)
  }

  if (PARITY.indexOf(settings.parity) === -1) {
    throw new TypeError(`"parity" is invalid: ${settings.parity}`)
  }

  FLOWCONTROLS.forEach(control => {
    if (typeof settings[control] !== 'boolean') {
      throw new TypeError(`"${control}" is not boolean: ${settings[control]}`)
    }
  })

  const binding = new Binding({
    bindingOptions: settings.bindingOptions,
  })

  Object.defineProperties(this, {
    binding: {
      enumerable: true,
      value: binding,
    },
    path: {
      enumerable: true,
      value: path,
    },
    settings: {
      enumerable: true,
      value: settings,
    },
  })

  this.opening = false
  this.closing = false
  this._pool = allocNewReadPool(this.settings.highWaterMark)
  this._kMinPoolSpace = 128

  if (this.settings.autoOpen) {
    this.open(openCallback)
  }
}

util.inherits(SerialPort, stream.Duplex)

Object.defineProperties(SerialPort.prototype, {
  isOpen: {
    enumerable: true,
    get() {
      return this.binding.isOpen && !this.closing
    },
  },
  baudRate: {
    enumerable: true,
    get() {
      return this.settings.baudRate
    },
  },
})

/**
 * The `error` event's callback is called with an error object whenever there is an error.
 * @event error
 */

SerialPort.prototype._error = function (error, callback) {
  if (callback) {
    callback.call(this, error)
  } else {
    this.emit('error', error)
  }
}

SerialPort.prototype._asyncError = function (error, callback) {
  process.nextTick(() => this._error(error, callback))
}

/**
 * The `open` event's callback is called with no arguments when the port is opened and ready for writing. This happens if you have the constructor open immediately (which opens in the next tick) or if you open the port manually with `open()`. See [Useage/Opening a Port](#opening-a-port) for more information.
 * @event open
 */

/**
 * Opens a connection to the given serial port.
 * @param {errorCallback=} openCallback - Called after a connection is opened. If this is not provided and an error occurs, it will be emitted on the port's `error` event.
 * @emits open
 * @returns {undefined}
 */
SerialPort.prototype.open = function (openCallback) {
  if (this.isOpen) {
    return this._asyncError(new Error('Port is already open'), openCallback)
  }

  if (this.opening) {
    return this._asyncError(new Error('Port is opening'), openCallback)
  }

  this.opening = true
  debug('opening', `path: ${this.path}`)
  this.binding.open(this.path, this.settings).then(
    () => {
      debug('opened', `path: ${this.path}`)
      this.opening = false
      this.emit('open')
      if (openCallback) {
        openCallback.call(this, null)
      }
    },
    err => {
      this.opening = false
      debug('Binding #open had an error', err)
      this._error(err, openCallback)
    }
  )
}

/**
 * Changes the baud rate for an open port. Throws if you provide a bad argument. Emits an error or calls the callback if the baud rate isn't supported.
 * @param {object=} options Only supports `baudRate`.
 * @param {number=} [options.baudRate] The baud rate of the port to be opened. This should match one of the commonly available baud rates, such as 110, 300, 1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, or 115200. Custom rates are supported best effort per platform. The device connected to the serial port is not guaranteed to support the requested baud rate, even if the port itself supports that baud rate.
 * @param {errorCallback=} [callback] Called once the port's baud rate changes. If `.update` is called without a callback, and there is an error, an error event is emitted.
 * @returns {undefined}
 */
SerialPort.prototype.update = function (options, callback) {
  if (typeof options !== 'object') {
    throw TypeError('"options" is not an object')
  }

  if (!this.isOpen) {
    debug('update attempted, but port is not open')
    return this._asyncError(new Error('Port is not open'), callback)
  }

  const settings = { ...defaultSettings, ...options }
  this.settings.baudRate = settings.baudRate

  debug('update', `baudRate: ${settings.baudRate}`)
  this.binding.update(this.settings).then(
    () => {
      debug('binding.update', 'finished')
      if (callback) {
        callback.call(this, null)
      }
    },
    err => {
      debug('binding.update', 'error', err)
      return this._error(err, callback)
    }
  )
}

/**
 * Writes data to the given serial port. Buffers written data if the port is not open.

The write operation is non-blocking. When it returns, data might still not have been written to the serial port. See `drain()`.

Some devices, like the Arduino, reset when you open a connection to them. In such cases, immediately writing to the device will cause lost data as they wont be ready to receive the data. This is often worked around by having the Arduino send a "ready" byte that your Node program waits for before writing. You can also often get away with waiting around 400ms.

If a port is disconnected during a write, the write will error in addition to the `close` event.

From the [stream docs](https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback) write errors don't always provide the error in the callback, sometimes they use the error event.
> If an error occurs, the callback may or may not be called with the error as its first argument. To reliably detect write errors, add a listener for the 'error' event.

In addition to the usual `stream.write` arguments (`String` and `Buffer`), `write()` can accept arrays of bytes (positive numbers under 256) which is passed to `Buffer.from([])` for conversion. This extra functionality is pretty sweet.
 * @method SerialPort.prototype.write
 * @param  {(string|array|buffer)} data Accepts a [`Buffer`](http://nodejs.org/api/buffer.html) object, or a type that is accepted by the `Buffer` constructor (e.g. an array of bytes or a string).
 * @param  {string=} encoding The encoding, if chunk is a string. Defaults to `'utf8'`. Also accepts `'ascii'`, `'base64'`, `'binary'`, and `'hex'` See [Buffers and Character Encodings](https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings) for all available options.
 * @param  {function=} callback Called once the write operation finishes. Data may not yet be flushed to the underlying port. No arguments.
 * @returns {boolean} `false` if the stream wishes for the calling code to wait for the `'drain'` event to be emitted before continuing to write additional data; otherwise `true`.
 * @since 5.0.0
 */
const superWrite = SerialPort.prototype.write
SerialPort.prototype.write = function (data, encoding, callback) {
  if (Array.isArray(data)) {
    data = Buffer.from(data)
  }
  return superWrite.call(this, data, encoding, callback)
}

SerialPort.prototype._write = function (data, encoding, callback) {
  if (!this.isOpen) {
    return this.once('open', function afterOpenWrite() {
      this._write(data, encoding, callback)
    })
  }
  debug('_write', `${data.length} bytes of data`)
  this.binding.write(data).then(
    () => {
      debug('binding.write', 'write finished')
      callback(null)
    },
    err => {
      debug('binding.write', 'error', err)
      if (!err.canceled) {
        this._disconnected(err)
      }
      callback(err)
    }
  )
}

SerialPort.prototype._writev = function (data, callback) {
  debug('_writev', `${data.length} chunks of data`)
  const dataV = data.map(write => write.chunk)
  this._write(Buffer.concat(dataV), null, callback)
}

/**
 * Request a number of bytes from the SerialPort. The `read()` method pulls some data out of the internal buffer and returns it. If no data is available to be read, null is returned. By default, the data is returned as a `Buffer` object unless an encoding has been specified using the `.setEncoding()` method.
 * @method SerialPort.prototype.read
 * @param {number=} size Specify how many bytes of data to return, if available
 * @returns {(string|Buffer|null)} The data from internal buffers
 * @since 5.0.0
 */

/**
 * Listening for the `data` event puts the port in flowing mode. Data is emitted as soon as it's received. Data is a `Buffer` object with a varying amount of data in it. The `readLine` parser converts the data into string lines. See the [parsers](https://serialport.io/docs/api-parsers-overview) section for more information on parsers, and the [Node.js stream documentation](https://nodejs.org/api/stream.html#stream_event_data) for more information on the data event.
 * @event data
 */

SerialPort.prototype._read = function (bytesToRead) {
  if (!this.isOpen) {
    debug('_read', 'queueing _read for after open')
    this.once('open', () => {
      this._read(bytesToRead)
    })
    return
  }

  if (!this._pool || this._pool.length - this._pool.used < this._kMinPoolSpace) {
    debug('_read', 'discarding the read buffer pool because it is below kMinPoolSpace')
    this._pool = allocNewReadPool(this.settings.highWaterMark)
  }

  // Grab another reference to the pool in the case that while we're
  // in the thread pool another read() finishes up the pool, and
  // allocates a new one.
  const pool = this._pool
  // Read the smaller of rest of the pool or however many bytes we want
  const toRead = Math.min(pool.length - pool.used, bytesToRead)
  const start = pool.used

  // the actual read.
  debug('_read', `reading`, { start, toRead })
  this.binding.read(pool, start, toRead).then(
    ({ bytesRead }) => {
      debug('binding.read', `finished`, { bytesRead })
      // zero bytes means read means we've hit EOF? Maybe this should be an error
      if (bytesRead === 0) {
        debug('binding.read', 'Zero bytes read closing readable stream')
        this.push(null)
        return
      }
      pool.used += bytesRead
      this.push(pool.slice(start, start + bytesRead))
    },
    err => {
      debug('binding.read', `error`, err)
      if (!err.canceled) {
        this._disconnected(err)
      }
      this._read(bytesToRead) // prime to read more once we're reconnected
    }
  )
}

SerialPort.prototype._disconnected = function (err) {
  if (!this.isOpen) {
    debug('disconnected aborted because already closed', err)
    return
  }
  debug('disconnected', err)
  err.disconnected = true
  this.close(null, err)
}

/**
 * The `close` event's callback is called with no arguments when the port is closed. In the case of a disconnect it will be called with a Disconnect Error object (`err.disconnected == true`). In the event of a close error (unlikely), an error event is triggered.
 * @event close
 */

/**
 * Closes an open connection.
 *
 * If there are in progress writes when the port is closed the writes will error.
 * @param {errorCallback} callback Called once a connection is closed.
 * @param {Error} disconnectError used internally to propagate a disconnect error
 * @emits close
 * @returns {undefined}
 */
SerialPort.prototype.close = function (callback, disconnectError) {
  disconnectError = disconnectError || null

  if (!this.isOpen) {
    debug('close attempted, but port is not open')
    return this._asyncError(new Error('Port is not open'), callback)
  }

  this.closing = true
  debug('#close')
  this.binding.close().then(
    () => {
      this.closing = false
      debug('binding.close', 'finished')
      this.emit('close', disconnectError)
      if (this.settings.endOnClose) {
        this.emit('end')
      }
      if (callback) {
        callback.call(this, disconnectError)
      }
    },
    err => {
      this.closing = false
      debug('binding.close', 'had an error', err)
      return this._error(err, callback)
    }
  )
}

/**
 * Set control flags on an open port. Uses [`SetCommMask`](https://msdn.microsoft.com/en-us/library/windows/desktop/aa363257(v=vs.85).aspx) for Windows and [`ioctl`](http://linux.die.net/man/4/tty_ioctl) for OS X and Linux.
 * @param {object=} options All options are operating system default when the port is opened. Every flag is set on each call to the provided or default values. If options isn't provided default options is used.
 * @param {Boolean} [options.brk=false] sets the brk flag
 * @param {Boolean} [options.cts=false] sets the cts flag
 * @param {Boolean} [options.dsr=false] sets the dsr flag
 * @param {Boolean} [options.dtr=true] sets the dtr flag
 * @param {Boolean} [options.rts=true] sets the rts flag
 * @param {errorCallback=} callback Called once the port's flags have been set.
 * @since 5.0.0
 * @returns {undefined}
 */
SerialPort.prototype.set = function (options, callback) {
  if (typeof options !== 'object') {
    throw TypeError('"options" is not an object')
  }

  if (!this.isOpen) {
    debug('set attempted, but port is not open')
    return this._asyncError(new Error('Port is not open'), callback)
  }

  const settings = { ...defaultSetFlags, ...options }
  debug('#set', settings)
  this.binding.set(settings).then(
    () => {
      debug('binding.set', 'finished')
      if (callback) {
        callback.call(this, null)
      }
    },
    err => {
      debug('binding.set', 'had an error', err)
      return this._error(err, callback)
    }
  )
}

/**
 * Returns the control flags (CTS, DSR, DCD) on the open port.
 * Uses [`GetCommModemStatus`](https://msdn.microsoft.com/en-us/library/windows/desktop/aa363258(v=vs.85).aspx) for Windows and [`ioctl`](http://linux.die.net/man/4/tty_ioctl) for mac and linux.
 * @param {modemBitsCallback=} callback Called once the modem bits are retrieved.
 * @returns {undefined}
 */
SerialPort.prototype.get = function (callback) {
  if (!this.isOpen) {
    debug('get attempted, but port is not open')
    return this._asyncError(new Error('Port is not open'), callback)
  }

  debug('#get')
  this.binding.get().then(
    status => {
      debug('binding.get', 'finished')
      if (callback) {
        callback.call(this, null, status)
      }
    },
    err => {
      debug('binding.get', 'had an error', err)
      return this._error(err, callback)
    }
  )
}

/**
 * Flush discards data received but not read, and written but not transmitted by the operating system. For more technical details, see [`tcflush(fd, TCIOFLUSH)`](http://linux.die.net/man/3/tcflush) for Mac/Linux and [`FlushFileBuffers`](http://msdn.microsoft.com/en-us/library/windows/desktop/aa364439) for Windows.
 * @param  {errorCallback=} callback Called once the flush operation finishes.
 * @returns {undefined}
 */
SerialPort.prototype.flush = function (callback) {
  if (!this.isOpen) {
    debug('flush attempted, but port is not open')
    return this._asyncError(new Error('Port is not open'), callback)
  }

  debug('#flush')
  this.binding.flush().then(
    () => {
      debug('binding.flush', 'finished')
      if (callback) {
        callback.call(this, null)
      }
    },
    err => {
      debug('binding.flush', 'had an error', err)
      return this._error(err, callback)
    }
  )
}

/**
 * Waits until all output data is transmitted to the serial port. After any pending write has completed it calls [`tcdrain()`](http://linux.die.net/man/3/tcdrain) or [FlushFileBuffers()](https://msdn.microsoft.com/en-us/library/windows/desktop/aa364439(v=vs.85).aspx) to ensure it has been written to the device.
 * @param {errorCallback=} callback Called once the drain operation returns.
 * @returns {undefined}
 * @example
Write the `data` and wait until it has finished transmitting to the target serial port before calling the callback. This will queue until the port is open and writes are finished.

```js
function writeAndDrain (data, callback) {
  port.write(data);
  port.drain(callback);
}
```
 */
SerialPort.prototype.drain = function (callback) {
  debug('drain')
  if (!this.isOpen) {
    debug('drain queuing on port open')
    return this.once('open', () => {
      this.drain(callback)
    })
  }
  this.binding.drain().then(
    () => {
      debug('binding.drain', 'finished')
      if (callback) {
        callback.call(this, null)
      }
    },
    err => {
      debug('binding.drain', 'had an error', err)
      return this._error(err, callback)
    }
  )
}

/**
 * The `pause()` method causes a stream in flowing mode to stop emitting 'data' events, switching out of flowing mode. Any data that becomes available remains in the internal buffer.
 * @method SerialPort.prototype.pause
 * @see resume
 * @since 5.0.0
 * @returns `this`
 */

/**
 * The `resume()` method causes an explicitly paused, `Readable` stream to resume emitting 'data' events, switching the stream into flowing mode.
 * @method SerialPort.prototype.resume
 * @see pause
 * @since 5.0.0
 * @returns `this`
 */

/**
 * Retrieves a list of available serial ports with metadata. Only the `path` is guaranteed. If unavailable the other fields will be undefined. The `path` is either the path or an identifier (eg `COM1`) used to open the SerialPort.
 *
 * We make an effort to identify the hardware attached and have consistent results between systems. Linux and OS X are mostly consistent. Windows relies on 3rd party device drivers for the information and is unable to guarantee the information. On windows If you have a USB connected device can we provide a serial number otherwise it will be `undefined`. The `pnpId` and `locationId` are not the same or present on all systems. The examples below were run with the same Arduino Uno.
 * @type {function}
 * @returns {Promise} Resolves with the list of available serial ports.
 * @example
```js
// OSX example port
{
  path: '/dev/tty.usbmodem1421',
  manufacturer: 'Arduino (www.arduino.cc)',
  serialNumber: '752303138333518011C1',
  pnpId: undefined,
  locationId: '14500000',
  productId: '0043',
  vendorId: '2341'
}

// Linux example port
{
  path: '/dev/ttyACM0',
  manufacturer: 'Arduino (www.arduino.cc)',
  serialNumber: '752303138333518011C1',
  pnpId: 'usb-Arduino__www.arduino.cc__0043_752303138333518011C1-if00',
  locationId: undefined,
  productId: '0043',
  vendorId: '2341'
}

// Windows example port
{
  path: 'COM3',
  manufacturer: 'Arduino LLC (www.arduino.cc)',
  serialNumber: '752303138333518011C1',
  pnpId: 'USB\\VID_2341&PID_0043\\752303138333518011C1',
  locationId: 'Port_#0003.Hub_#0001',
  productId: '0043',
  vendorId: '2341'
}
```

```js
var SerialPort = require('serialport');

// promise approach
SerialPort.list()
  .then(ports) {...});
  .catch(err) {...});
```
 */
SerialPort.list = async function (callback) {
  debug('.list')
  if (!SerialPort.Binding) {
    throw new TypeError('No Binding set on `SerialPort.Binding`')
  }
  if (callback) {
    throw new TypeError('SerialPort.list no longer takes a callback and only returns a promise')
  }
  return SerialPort.Binding.list()
}

module.exports = SerialPort
