const dgram = require('dgram')
const {
  createUDPHeader,
  decodeUserData28,
  decodeRecordData16,
  decodeRecordRealTimeLog18,
  decodeUDPHeader,
  exportErrorMessage,
  checkNotEventUDP
} = require('./utils')

const { MAX_CHUNK, REQUEST_DATA, COMMANDS } = require('./constants')

const { log } = require('./helpers/errorLog')

class ZKLibUDP {
  constructor(ip, port, timeout, inport) {
    this.ip = ip
    this.port = port
    this.timeout = timeout
    this.socket = null
    this.sessionId = null
    this.replyId = 0
    this.inport = inport
  }

  createSocket(cbError, cbClose) {
    return new Promise((resolve, reject) => {
      this.socket = dgram.createSocket('udp4');
      this.socket.setMaxListeners(Infinity)
      this.socket.once('error', err => {
        reject(err)
        cbError && cbError(err)
      })

      this.socket.on('close', (err) => {
        this.socket = null;
        cbClose && cbClose('udp')
      })

      this.socket.once('listening', () => {
        resolve(this.socket)
      })
      try {
        this.socket.bind(this.inport)
      } catch (err) {
      }

    })
  }

  connect() {
    return new Promise(async (resolve, reject) => {
      try {
        const reply = await this.executeCmd(COMMANDS.CMD_CONNECT, '')
        if (reply) {
          resolve(true)
        } else {
          reject(new Error('NO_REPLY_ON_CMD_CONNECT'))
        }
      } catch (err) {
        reject(err)
      }
    })
  }


  closeSocket() {
    return new Promise((resolve, reject) => {
      this.socket.removeAllListeners('message')
      this.socket.close(() => {
        clearTimeout(timer)
        resolve(true)
      })

      /**
       * When socket isn't connected so this.socket.end will never resolve
       * we use settimeout for handling this case
       */
      const timer = setTimeout(() => {
        resolve(true)
      }, 2000)
    })
  }

  writeMessage(msg, connect) {
    return new Promise((resolve, reject) => {
      let sendTimeoutId;
      this.socket.once('message', (data) => {
        sendTimeoutId && clearTimeout(sendTimeoutId)
        resolve(data)
      })

      this.socket.send(msg, 0, msg.length, this.port, this.ip, (err) => {
        if (err) {
          reject(err)
        }
        if (this.timeout) {
          sendTimeoutId = setTimeout(() => {
            clearTimeout(sendTimeoutId)
            reject(new Error('TIMEOUT_ON_WRITING_MESSAGE'))
          }, connect ? 2000 : this.timeout)
        }
      })
    })
  }

  requestData(msg) {
    return new Promise((resolve, reject) => {
      let sendTimeoutId
      const internalCallback = (data) => {
        sendTimeoutId && clearTimeout(sendTimeoutId)
        this.socket.removeListener('message', handleOnData)
        resolve(data)
      }

      const handleOnData = (data) => {
        if (checkNotEventUDP(data)) return;
        clearTimeout(sendTimeoutId)
        sendTimeoutId = setTimeout(() => {
          reject(new Error('TIMEOUT_ON_RECEIVING_REQUEST_DATA'))
        }, this.timeout)

        if (data.length >= 13) {
          internalCallback(data)
        }

      }

      this.socket.on('message', handleOnData)

      this.socket.send(msg, 0, msg.length, this.port, this.ip, (err) => {
        if (err) {
          reject(err)
        }
        sendTimeoutId = setTimeout(() => {
          reject(Error('TIMEOUT_IN_RECEIVING_RESPONSE_AFTER_REQUESTING_DATA'))
        }, this.timeout)

      })
    })

  }

  /**
  * 
  * @param {*} command 
  * @param {*} data 
  * 
  * 
  * reject error when command fail and resolve data when success
  */
  executeCmd(command, data) {
    return new Promise(async (resolve, reject) => {
      try {
        if (command === COMMANDS.CMD_CONNECT) {
          this.sessionId = 0
          this.replyId = 0
        } else {
          this.replyId++
        }


        const buf = createUDPHeader(command, this.sessionId, this.replyId, data)
        const reply = await this.writeMessage(buf, command === COMMANDS.CMD_CONNECT || command === COMMANDS.CMD_EXIT)

        if (reply && reply.length && reply.length >= 0) {
          if (command === COMMANDS.CMD_CONNECT) {
            this.sessionId = reply.readUInt16LE(4);
          }
        }
        resolve(reply)
      } catch (err) {
        reject(err)
      }
    })
  }


  sendChunkRequest(start, size) {
    this.replyId++;
    const reqData = Buffer.alloc(8)
    reqData.writeUInt32LE(start, 0)
    reqData.writeUInt32LE(size, 4)
    const buf = createUDPHeader(COMMANDS.CMD_DATA_RDY, this.sessionId, this.replyId, reqData)

    this.socket.send(buf, 0, buf.length, this.port, this.ip, (err) => {
      if (err) {
        if (err) {
          log(`[UDP][SEND_CHUNK_REQUEST]` + err.toString())
        }
      }
    })
  }



  /**
   * 
   * @param {*} reqData - indicate the type of data that need to receive ( user or attLog)
   * @param {*} cb - callback is triggered when receiving packets
   * 
   * readWithBuffer will reject error if it'wrong when starting request data 
   * readWithBuffer will return { data: replyData , err: Error } when receiving requested data
   */
  readWithBuffer(reqData, cb = null) {
    return new Promise(async (resolve, reject) => {
      this.replyId++;
      const buf = createUDPHeader(COMMANDS.CMD_DATA_WRRQ, this.sessionId, this.replyId, reqData)


      let reply = null
      try {
        reply = await this.requestData(buf)
      } catch (err) {
        reject(err)
      }

      const header = decodeUDPHeader(reply.subarray(0, 8))

      switch (header.commandId) {
        case COMMANDS.CMD_DATA: {
          resolve({ data: reply.subarray(8), mode: 8, err: null })
          break;
        }
        case COMMANDS.CMD_ACK_OK:
        case COMMANDS.CMD_PREPARE_DATA: {
          // this case show that data is prepared => send command to get these data 
          // reply variable includes information about the size of following data 
          const recvData = reply.subarray(8)
          const size = recvData.readUIntLE(1, 4)

          // We need to split the data to many chunks to receive , because it's to large
          // After receiving all chunk data , we concat it to TotalBuffer variable , that 's the data we want
          let remain = size % MAX_CHUNK
          let numberChunks = Math.round(size - remain) / MAX_CHUNK

          let totalBuffer = Buffer.from([])


          const timeout = 3000
          let timer = setTimeout(() => {
            internalCallback(totalBuffer, new Error('TIMEOUT WHEN RECEIVING PACKET'))
          }, timeout)


          const internalCallback = (replyData, err = null) => {
            this.socket.removeListener('message', handleOnData)
            timer && clearTimeout(timer)
            if (err) {
              resolve({ err, data: replyData })
            } else {
              resolve({ err: null, data: replyData })
            }
          }


          const handleOnData = (reply) => {
            if (checkNotEventUDP(reply)) return;
            clearTimeout(timer)
            timer = setTimeout(() => {
              internalCallback(totalBuffer,
                new Error(`TIMEOUT !! ${(size - totalBuffer.length) / size} % REMAIN !  `))
            }, timeout)
            const header = decodeUDPHeader(reply)

            switch (header.commandId) {
              case COMMANDS.CMD_PREPARE_DATA: {
                break;
              }
              case COMMANDS.CMD_DATA: {
                totalBuffer = Buffer.concat([totalBuffer, reply.subarray(8)])
                cb && cb(totalBuffer.length, size)
                break;
              }
              case COMMANDS.CMD_ACK_OK: {
                if (totalBuffer.length === size) {
                  internalCallback(totalBuffer)
                }
                break;
              }
              default: {
                internalCallback([], new Error('ERROR_IN_UNHANDLE_CMD ' + exportErrorMessage(header.commandId)))
              }
            }
          }

          this.socket.on('message', handleOnData);

          for (let i = 0; i <= numberChunks; i++) {
            if (i === numberChunks) {
              this.sendChunkRequest(numberChunks * MAX_CHUNK, remain)
            } else {
              this.sendChunkRequest(i * MAX_CHUNK, MAX_CHUNK)
            }
          }

          break;
        }
        default: {
          reject(new Error('ERROR_IN_UNHANDLE_CMD ' + exportErrorMessage(header.commandId)))
        }
      }
    })
  }


  async getUsers() {

    // Free Buffer Data to request Data
    if (this.socket) {
      try {
        await this.freeData()
      } catch (err) {
        return Promise.reject(err)
      }
    }


    let data = null
    try {
      data = await this.readWithBuffer(REQUEST_DATA.GET_USERS)
    } catch (err) {
      return Promise.reject(err)
    }

    // Free Buffer Data after requesting data
    if (this.socket) {
      try {
        await this.freeData()
      } catch (err) {
        return Promise.reject(err)
      }
    }

    const USER_PACKET_SIZE = 28
    let userData = data.data.subarray(4)
    let users = []

    while (userData.length >= USER_PACKET_SIZE) {
      const user = decodeUserData28(userData.subarray(0, USER_PACKET_SIZE))
      users.push(user)
      userData = userData.subarray(USER_PACKET_SIZE)
    }

    return { data: users, err: data.err }

  }


  /**
   * 
   * @param {*} ip 
   * @param {*} callbackInProcess  
   *  reject error when starting request data
   *  return { data: records, err: Error } when receiving requested data
   */


  async getAttendances(callbackInProcess = () => { }) {
    if (this.socket) {
      try {
        await this.freeData()
      } catch (err) {
        return Promise.reject(err)
      }
    }

    let data = null
    try{
      data = await this.readWithBuffer(REQUEST_DATA.GET_ATTENDANCE_LOGS, callbackInProcess)
    }catch(err){
      return Promise.reject(err)
    }
    
    if (this.socket) {
      try {
        await this.freeData()
      } catch (err) {
        return Promise.reject(err)
      }
    }

    if (data.mode) {
      // Data too small to decode in a normal way  => we need a parameter to indicate this case 
      const RECORD_PACKET_SIZE = 8
      let recordData = data.data.subarray(4)

      let records = []
      while (recordData.length >= RECORD_PACKET_SIZE) {
        const record = decodeRecordData16(recordData.subarray(0, RECORD_PACKET_SIZE))
        records.push({ ...record, ip: this.ip })
        recordData = recordData.subarray(RECORD_PACKET_SIZE)
      }

      return { data: records, err: data.err }

    } else {
      const RECORD_PACKET_SIZE = 16
      let recordData = data.data.subarray(4)

      let records = []
      while (recordData.length >= RECORD_PACKET_SIZE) {
        const record = decodeRecordData16(recordData.subarray(0, RECORD_PACKET_SIZE))
        records.push({ ...record, ip: this.ip })
        recordData = recordData.subarray(RECORD_PACKET_SIZE)
      }

      return { data: records, err: data.err }
    }

  }



  async freeData() {
    return await this.executeCmd(COMMANDS.CMD_FREE_DATA, '')
  }


  async getInfo() {
    const data = await this.executeCmd(COMMANDS.CMD_GET_FREE_SIZES, '')
    try {
      return {
        userCounts: data.readUIntLE(24, 4),
        logCounts: data.readUIntLE(40, 4),
        logCapacity: data.readUIntLE(72, 4)
      }
    } catch (err) {
      return Promise.reject(err)
    }
  }

  async getTime() {
    try {
      const t = await this.executeCmd(COMMANDS.CMD_GET_TIME, '')
      return timeParser.decode(t.readUInt32LE(8));
    }
    catch(err){
      return Promise.reject(err);
    }
  }

  async clearAttendanceLog (){
    return await this.executeCmd(COMMANDS.CMD_CLEAR_ATTLOG, '')
  }


  async disableDevice() {
    return await this.executeCmd(COMMANDS.CMD_DISABLEDEVICE, REQUEST_DATA.DISABLE_DEVICE)
  }

  async enableDevice() {
    return await this.executeCmd(COMMANDS.CMD_ENABLEDEVICE, '')
  }

  async disconnect() {
    try {
      await this.executeCmd(COMMANDS.CMD_EXIT, '')
    } catch (err) {
    }
    return await this.closeSocket()
  }



  async getRealTimeLogs(cb = () => { }) {
    this.replyId++;
    const buf = createUDPHeader(COMMANDS.CMD_REG_EVENT, this.sessionId, this.replyId, REQUEST_DATA.GET_REAL_TIME_EVENT)

    this.socket.send(buf, 0, buf.length, this.port, this.ip, (err) => {

    })

    this.socket.listenerCount('message') < 2 && this.socket.on('message', (data) => {

      if (!checkNotEventUDP(data)) return;
      if (data.length === 18) {
        cb(decodeRecordRealTimeLog18(data))
      }
    })

  }
}




module.exports = ZKLibUDP