import { createSocket } from 'dgram'
import * as t from './types'

export * as t from './types'

export type Options = {
  listId: number
  // todo change to unknown
  onChange?: (name: string, value: any) => void // eslint-disable-line
  cyclic?: boolean
  cycleInterval?: number
  packed?: boolean
}

interface ClientOptions {
  /// Port to listen to
  port?: number
  /// Optional port to use when sending data
  /// (if not specified, `port` is used to send data as well)
  send_port?: number
  /// If true, sent and received packages are printed to the terminal
  debug?: boolean
}

type OnMessage = (varId: number, value: Buffer) => void

type ListenerList = { listId: number; cb: OnMessage }[]

/// endpoint: default value '255.255.255.255'
export const client = (endpoint: string = '255.255.255.255', clientopts?: ClientOptions) => {
  const listeners: ListenerList = []

  const port = clientopts?.port || 1202
  const write_port = clientopts?.send_port || port
  const debug = clientopts?.debug || false

  const socket = createSocket('udp4', (msg) => {
    if (msg.length < 20) {
      return
    }
    const data = msg.toString('hex')
    const varId = parseInt(data.substring(18, 22), 16)
    const listId = parseInt(data.substring(16, 18), 16)

    if (debug) {
      console.log(`RECV (listId: ${listId}, from ${endpoint}:${write_port}): ${data}`)
    }
    listeners.filter((l) => l.listId == listId).forEach((l) => l.cb(varId, msg.subarray(20)))
  })

  socket.bind(port)

  const mkValue = (def: t.Types): { data: string; lng: number } => {
    const out = Buffer.alloc(250)
    let lng = 0
    switch (def.type) {
      case 'BOOL':
        return { data: def.value ? '01' : '00', lng: 1 }
      case 'BYTE':
        lng = out.writeInt8(def.value)
        break
      case 'WORD':
        lng = out.writeUInt16LE(def.value)
        break
      case 'DWORD':
        lng = out.writeUInt32LE(def.value)
      case 'TIME':
        lng = out.writeInt32LE(def.value)
        break
      case 'REAL':
        lng = out.writeFloatLE(def.value)
        break
      case 'LREAL':
        lng = out.writeDoubleLE(def.value)
        break
      case 'STRING':
        lng = out.write(def.value, 'ascii')
        lng = out.writeInt8(0, lng)
        break
      case 'WSTRING':
        lng = out.write(def.value, 'utf16le')
        lng = out.writeInt16LE(0, lng)
        break
    }

    return {
      data: out.subarray(0, lng).toString('hex'),
      lng,
    }
  }
  const mkLng = (lng: number): string => {
    const lngBuf = Buffer.alloc(2)
    lngBuf.writeUInt16LE(lng + 20) // 20 is for the header
    return lngBuf.toString('hex')
  }

  const d2h = (d: number, l: number) => {
    let bn = BigInt(d)

    let pos = true
    if (bn < 0) {
      pos = false
      bn = bitnot(bn)
    }

    let hex = bn.toString(16)
    if (hex.length % 2) {
      hex = '0' + hex
    }

    if (pos && 0x80 & parseInt(hex.slice(0, 2), 16)) {
      hex = '00' + hex
    }

    return (hex.length % 2 ? '0' + hex : hex).padEnd(l, '0')
  }

  const bitnot = (bn: bigint) => {
    bn = BigInt(-bn)
    let bin = bn.toString(2)
    let prefix = ''
    while (bin.length % 8) {
      bin = '0' + bin
    }
    if ('1' === bin[0] && -1 !== bin.slice(1).indexOf('1')) {
      prefix = '11111111'
    }
    bin = bin
      .split('')
      .map(function (i) {
        return '0' === i ? '1' : '0'
      })
      .join('')
    return BigInt('0b' + prefix + bin) + BigInt(1)
  }

  type Return<T extends { [key: string]: t.Types }> = {
    set: <K extends keyof T>(name: K, value: T[K]['value']) => boolean
    setMore: (set: { [K in keyof T]?: T[K]['value'] }) => boolean
    get: <K extends keyof T>(name: K) => T[K]['value'] | undefined
    definition: string
    dispose: () => void
  }

  const list = <T extends { [k: string]: t.Types }>(options: Options, vars: T): Return<T> => {
    const { listId, onChange, cyclic, cycleInterval, packed } = options
    const nodeId = '002d5333'
    const listIdStr = d2h(listId, 4)

    let packedSendCounter = 0

    const write_state: T = JSON.parse(JSON.stringify(vars)) //clone to save write state separately
    const sortedIdx = Object.entries(write_state)
      .sort((a, b) => a[1].idx - b[1].idx)
      .map(([name, _]) => name)

    let state = { ...vars }

    let cycleIntervalTimer: NodeJS.Timeout | undefined = undefined
    if (cyclic) {
      const interval = cycleInterval || 1000
      cycleIntervalTimer = setInterval(
        () => (packed ? sendPacked(write_state) : send(write_state)),
        interval,
      )
    }

    const getVarName = (idx: number): keyof T | undefined => {
      return Object.entries(vars)
        .filter(([, v]) => v.idx === idx)
        .map(([key]) => key)
        .shift()
    }

    const getNextSendCounter: Record<keyof T, number> = Object.keys(state).reduce(
      (acc, n) => ({ ...acc, [n]: -1 }),
      {} as Record<keyof T, number>,
    )

    const getCounter = (key: keyof T) => {
      return getNextSendCounter[key]++
    }

    const getPackedSendCounter = (): number => {
      packedSendCounter += 1
      if (packedSendCounter > 65535) packedSendCounter = 0
      return packedSendCounter
    }

    const sendPacked = (write_state: T) => {
      const counter = d2h(getPackedSendCounter(), 4)
      const vars = sortedIdx.map((name) => {
        return mkValue(write_state[name])
      })
      const lng = d2h(vars.reduce((sum, current) => sum + current.lng, 0) + 20, 4) //add 20 bytes for the header
      const data = vars.map((current, _lng) => current.data).join('')
      const items = d2h(vars.length, 4)
      const cmdStr = `${nodeId}00000000${listIdStr}0000${items}${lng}${counter}0000${data}`
      const cmd = Buffer.from(cmdStr, 'hex')

      if (debug) {
        console.log(`SEND (${endpoint}:${write_port}): ${cmdStr}`)
      }
      socket.send(cmd, write_port, endpoint)
    }

    const send = (send: Partial<T>) => {
      Object.entries(send)
        .filter((toSend): toSend is [string, t.Types] => true)
        .map(([name, toSend]) => {
          const { data, lng } = mkValue(toSend)
          return {
            idx: d2h(toSend.idx, 2),
            counter: d2h(getCounter(name), 2),
            data,
            lng: mkLng(lng),
          }
        })
        .map(({ idx, lng, counter, data }) => {
          const str = `${nodeId}00000000${listIdStr}${idx}0100${lng}${counter}0000${data}`
          if (debug) {
            console.log(`SEND (${endpoint}:${write_port}): ${str}`)
          }
          return Buffer.from(str, 'hex')
        })
        .forEach((cmd) => {
          socket.send(cmd, write_port, endpoint)
        })
    }

    const readIntoVar = (varName: string, data: Buffer, offset: number): number => {
      let bytesRead = 0

      //TODO - maybe improve error handling at some point
      try {
        const selVar = state[varName]
        const oldValue = selVar.value

        switch (selVar.type) {
          case 'BOOL':
            selVar.value = data.readInt8(offset) !== 0
            bytesRead = 1
            break
          case 'BYTE':
            selVar.value = data.readInt8(offset)
            bytesRead = 1
            break
          case 'WORD':
            selVar.value = data.readInt16LE(offset)
            bytesRead = 2
            break
          case 'DWORD':
            selVar.value = data.readInt32LE(offset)
            bytesRead = 4
            break
          case 'STRING': {
            const strdata = data.slice(offset)
            const length = strdata.findIndex((c) => c === 0)
            selVar.value = strdata.toString('ascii', offset, length === -1 ? undefined : length)
            bytesRead = length === -1 ? 0 : length
            break
          }
          case 'WSTRING': {
            const strdata = data.slice(offset)
            const length = strdata.findIndex((c) => c === 0)
            selVar.value = strdata.toString('utf16le', offset, length === -1 ? undefined : length)
            bytesRead = length === -1 ? 0 : length
            break
          }
          case 'TIME':
            selVar.value = data.readInt32LE(offset)
            bytesRead = 4
            break
          case 'REAL':
            selVar.value = data.readFloatLE(offset)
            bytesRead = 4
            break
          case 'LREAL':
            selVar.value = data.readDoubleLE(offset)
            bytesRead = 8
            break
          default: {
            //selVar.value = data.readInt8()
          }
        }

        if (oldValue !== selVar.value && onChange) {
          onChange(`${varName}`, selVar.value)
        }
      } catch {}
      return bytesRead
    }

    const onMessage = (varId: number, data: Buffer) => {
      if (varId === 0) {
        let offset = 0
        sortedIdx.forEach((name) => {
          if (name) {
            offset += readIntoVar(name, data, offset)
          }
        })
      } else {
        const varName = getVarName(varId)
        if (typeof varName === 'string') {
          readIntoVar(varName, data, 0)
        }
      }
    }
    listeners.push({ listId, cb: onMessage })

    const definition = `<GVL>
  <Declarations><![CDATA[VAR_GLOBAL
${Object.entries(state)
  .sort((a, b) => a[1].idx - b[1].idx)
  .map(([name, def]) => `        ${name}: ${def.type};`)
  .join('\n')}
END_VAR]]></Declarations>
  <NetvarSettings Protocol="UDP">
    <ListIdentifier>${listId}</ListIdentifier>
    <Pack>False</Pack>
    <Checksum>False</Checksum>
    <Acknowledge>False</Acknowledge>
    <CyclicTransmission>${options.cyclic ? 'True' : 'False'}</CyclicTransmission>
    <TransmissionOnChange>True</TransmissionOnChange>
    <TransmissionOnEvent>False</TransmissionOnEvent>
    <Interval>T#${options.cycleInterval || 9000000}ms</Interval>
    <MinGap>T#1ms</MinGap>
    <EventVariable>
    </EventVariable>
    <ProtocolSettings>
      <ProtocolSetting Name="Broadcast Adr." Value="${endpoint}"/>
      <ProtocolSetting Name="Port" Value="${port}"/>
    </ProtocolSettings>
  </NetvarSettings>
</GVL>`

    return {
      set: (name, value): boolean => {
        if (name in state) {
          write_state[name].value = value
          packed
            ? sendPacked(write_state)
            : send({ [name]: write_state[name] } as any as Partial<T>) // eslint-disable-line
          return true
        }
        return false
      },
      setMore: (set): boolean => {
        try {
          state = Object.entries(set).reduce(
            (acc, [name, value]) => ({ ...acc, [name]: { ...acc[name], value } }),
            state,
          )
          const newSet = Object.entries(set).reduce(
            (acc, [name, value]) => ({
              ...acc,
              [name]: { ...state[name], value },
            }),
            {},
          )
          packed ? sendPacked(write_state) : send(newSet)
          return true
        } catch {
          return false
        }
      },
      get: (name) => (name in state ? state[name].value : undefined),
      definition,
      dispose: () => cycleIntervalTimer && clearInterval(cycleIntervalTimer),
    }
  }

  return {
    openList: list,
  }
}
