const AbstractBinding = require('@serialport/binding-abstract')
const debug = require('debug')('serialport/binding-mock')
const { wrapWithHiddenComName } = require('@serialport/binding-mock/lib/legacy')

let ports = {}
let serialNumber = 0

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

const cancelError = message => {
  const err = new Error(message)
  err.canceled = true
  return err
}

/**
 * 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 = {}
    serialNumber = 0
  }

  // Create a mock port
  static createPort(path, opt) {
    serialNumber++
    opt = {
      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: {
        path,
        manufacturer: opt.manufacturer,
        serialNumber,
        pnpId: undefined,
        locationId: undefined,
        vendorId: opt.vendorId,
        productId: opt.productId,
      },
    }
    debug(serialNumber, 'created port', JSON.stringify({ path, opt }))
  }

  static async list() {
    return wrapWithHiddenComName(Object.values(ports).map(port => port.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
    }
  }

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

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

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

    port.openOpt = { ...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)
        }
      })
    }
  }

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

    await super.close()
    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(cancelError('port is closed'))
    }
  }

  async read(buffer, offset, length) {
    debug(this.serialNumber, 'reading', length, 'bytes')
    await super.read(buffer, offset, length)
    await resolveNextTick()
    if (!this.isOpen) {
      throw cancelError('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 bytesRead = data.copy(buffer, offset)
    this.port.data = this.port.data.slice(length)
    debug(this.serialNumber, 'read', bytesRead, 'bytes')
    return { bytesRead, buffer }
  }

  async 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(async () => {
      await resolveNextTick()
      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
  }

  async update(opt) {
    await super.update(opt)
    await resolveNextTick()
    this.port.openOpt.baudRate = opt.baudRate
  }

  async set(opt) {
    await super.set(opt)
    await resolveNextTick()
  }

  async get() {
    await super.get()
    await resolveNextTick()
    return {
      cts: true,
      dsr: false,
      dcd: false,
    }
  }

  async getBaudRate() {
    await super.getBaudRate()
    await resolveNextTick()
    return {
      baudRate: this.port.openOpt.baudRate,
    }
  }

  async flush() {
    await super.flush()
    await resolveNextTick()
    this.port.data = Buffer.alloc(0)
  }

  async drain() {
    await super.drain()
    await this.writeOperation
    await resolveNextTick()
  }
}

module.exports = MockBinding
