const AbstractBinding = require('@serialport/binding-abstract')
const debug = require('debug')('serialport/binding-mock')

let ports = {}
let serialNumber = 0

function resolveNextTick(value) {
  return new Promise(resolve => process.nextTick(() => resolve(value)))
}

/**
 * Mock bindings for pretend serialport access
 */
class MockBinding extends AbstractBinding {
  constructor(opt) {
    super(opt)
    this.pendingRead = null // thunk for a promise or null
    this.isOpen = false
    this.port = null
    this.lastWrite = null
    this.recording = Buffer.alloc(0)
    this.writeOperation = null // in flight promise or null
  }

  // Reset mocks
  static reset() {
    ports = {}
  }

  // Create a mock port
  static createPort(path, opt) {
    serialNumber++
    opt = Object.assign(
      {
        echo: false,
        record: false,
        readyData: Buffer.from('READY'),
        manufacturer: 'The J5 Robotics Company',
        vendorId: undefined,
        productId: undefined,
      },
      opt
    )

    ports[path] = {
      data: Buffer.alloc(0),
      echo: opt.echo,
      record: opt.record,
      readyData: Buffer.from(opt.readyData),
      info: {
        comName: path,
        manufacturer: opt.manufacturer,
        serialNumber,
        pnpId: undefined,
        locationId: undefined,
        vendorId: opt.vendorId,
        productId: opt.productId,
      },
    }
    debug(serialNumber, 'created port', JSON.stringify({ path, opt }))
  }

  static list() {
    const info = Object.keys(ports).map(path => {
      return ports[path].info
    })
    return Promise.resolve(info)
  }

  // Emit data on a mock port
  emitData(data) {
    if (!this.isOpen) {
      throw new Error('Port must be open to pretend to receive data')
    }
    if (!Buffer.isBuffer(data)) {
      data = Buffer.from(data)
    }
    debug(this.serialNumber, 'emitting data - pending read:', Boolean(this.pendingRead))
    this.port.data = Buffer.concat([this.port.data, data])
    if (this.pendingRead) {
      process.nextTick(this.pendingRead)
      this.pendingRead = null
    }
  }

  open(path, opt) {
    debug(null, `opening path ${path}`)
    const port = (this.port = ports[path])
    return super
      .open(path, opt)
      .then(resolveNextTick)
      .then(() => {
        if (!port) {
          return Promise.reject(new Error(`Port does not exist - please call MockBinding.createPort('${path}') first`))
        }
        this.serialNumber = port.info.serialNumber

        if (port.openOpt && port.openOpt.lock) {
          return Promise.reject(new Error('Port is locked cannot open'))
        }

        if (this.isOpen) {
          return Promise.reject(new Error('Open: binding is already open'))
        }

        port.openOpt = Object.assign({}, opt)
        this.isOpen = true
        debug(this.serialNumber, 'port is open')
        if (port.echo) {
          process.nextTick(() => {
            if (this.isOpen) {
              debug(this.serialNumber, 'emitting ready data')
              this.emitData(port.readyData)
            }
          })
        }
      })
  }

  close() {
    const port = this.port
    debug(this.serialNumber, 'closing port')
    if (!port) {
      return Promise.reject(new Error('already closed'))
    }

    return super.close().then(() => {
      delete port.openOpt
      // reset data on close
      port.data = Buffer.alloc(0)
      debug(this.serialNumber, 'port is closed')
      delete this.port
      delete this.serialNumber
      this.isOpen = false
      if (this.pendingRead) {
        this.pendingRead(new Error('port is closed'))
      }
    })
  }

  read(buffer, offset, length) {
    debug(this.serialNumber, 'reading', length, 'bytes')
    return super
      .read(buffer, offset, length)
      .then(resolveNextTick)
      .then(() => {
        if (!this.isOpen) {
          throw new Error('Read canceled')
        }
        if (this.port.data.length <= 0) {
          return new Promise((resolve, reject) => {
            this.pendingRead = err => {
              if (err) {
                return reject(err)
              }
              this.read(buffer, offset, length).then(resolve, reject)
            }
          })
        }
        const data = this.port.data.slice(0, length)
        const readLength = data.copy(buffer, offset)
        this.port.data = this.port.data.slice(length)
        debug(this.serialNumber, 'read', readLength, 'bytes')
        return readLength
      })
  }

  write(buffer) {
    debug(this.serialNumber, 'writing')
    if (this.writeOperation) {
      throw new Error('Overlapping writes are not supported and should be queued by the serialport object')
    }
    this.writeOperation = super
      .write(buffer)
      .then(resolveNextTick)
      .then(() => {
        if (!this.isOpen) {
          throw new Error('Write canceled')
        }
        const data = (this.lastWrite = Buffer.from(buffer)) // copy
        if (this.port.record) {
          this.recording = Buffer.concat([this.recording, data])
        }
        if (this.port.echo) {
          process.nextTick(() => {
            if (this.isOpen) {
              this.emitData(data)
            }
          })
        }
        this.writeOperation = null
        debug(this.serialNumber, 'writing finished')
      })
    return this.writeOperation
  }

  update(opt) {
    return super
      .update(opt)
      .then(resolveNextTick)
      .then(() => {
        this.port.openOpt.baudRate = opt.baudRate
      })
  }

  set(opt) {
    return super.set(opt).then(resolveNextTick)
  }

  get() {
    return super
      .get()
      .then(resolveNextTick)
      .then(() => {
        return {
          cts: true,
          dsr: false,
          dcd: false,
        }
      })
  }

  getBaudRate() {
    return super
      .getBaudRate()
      .then(resolveNextTick)
      .then(() => {
        return {
          baudRate: this.port.openOpt.baudRate,
        }
      })
  }

  flush() {
    return super
      .flush()
      .then(resolveNextTick)
      .then(() => {
        this.port.data = Buffer.alloc(0)
      })
  }

  drain() {
    return super
      .drain()
      .then(() => this.writeOperation)
      .then(() => resolveNextTick())
  }
}

module.exports = MockBinding
