// This file draws heavily from https://github.com/phoenixframework/phoenix/commit/cf098e9cf7a44ee6479d31d911a97d3c7430c6fe
// License: https://github.com/phoenixframework/phoenix/blob/master/LICENSE.md
export type Msg<T> = {
  join_ref?: string | null
  ref?: string | null
  topic: string
  event: string
  payload: T
}

export default class Serializer {
  HEADER_LENGTH = 1
  USER_BROADCAST_PUSH_META_LENGTH = 6
  KINDS = { userBroadcastPush: 3, userBroadcast: 4 }
  BINARY_ENCODING = 0
  JSON_ENCODING = 1
  BROADCAST_EVENT = 'broadcast'

  allowedMetadataKeys: string[] = []

  constructor(allowedMetadataKeys?: string[] | null) {
    this.allowedMetadataKeys = allowedMetadataKeys ?? []
  }

  encode(msg: Msg<{ [key: string]: any }>, callback: (result: ArrayBuffer | string) => any) {
    if (
      msg.event === this.BROADCAST_EVENT &&
      !(msg.payload instanceof ArrayBuffer) &&
      typeof msg.payload.event === 'string'
    ) {
      return callback(
        this._binaryEncodeUserBroadcastPush(msg as Msg<{ event: string } & { [key: string]: any }>)
      )
    }

    let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]
    return callback(JSON.stringify(payload))
  }

  private _binaryEncodeUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) {
    if (this._isArrayBuffer(message.payload?.payload)) {
      return this._encodeBinaryUserBroadcastPush(message)
    } else {
      return this._encodeJsonUserBroadcastPush(message)
    }
  }

  private _encodeBinaryUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) {
    const userPayload = message.payload?.payload ?? new ArrayBuffer(0)
    return this._encodeUserBroadcastPush(message, this.BINARY_ENCODING, userPayload)
  }

  private _encodeJsonUserBroadcastPush(message: Msg<{ event: string } & { [key: string]: any }>) {
    const userPayload = message.payload?.payload ?? {}
    const encoder = new TextEncoder()
    const encodedUserPayload = encoder.encode(JSON.stringify(userPayload)).buffer
    return this._encodeUserBroadcastPush(message, this.JSON_ENCODING, encodedUserPayload)
  }

  private _encodeUserBroadcastPush(
    message: Msg<{ event: string } & { [key: string]: any }>,
    encodingType: number,
    encodedPayload: ArrayBuffer
  ) {
    const topic = message.topic
    const ref = message.ref ?? ''
    const joinRef = message.join_ref ?? ''
    const userEvent = message.payload.event

    // Filter metadata based on allowed keys
    const rest = this.allowedMetadataKeys
      ? this._pick(message.payload, this.allowedMetadataKeys)
      : {}

    const metadata = Object.keys(rest).length === 0 ? '' : JSON.stringify(rest)

    // Validate lengths don't exceed uint8 max value (255)
    if (joinRef.length > 255) {
      throw new Error(`joinRef length ${joinRef.length} exceeds maximum of 255`)
    }
    if (ref.length > 255) {
      throw new Error(`ref length ${ref.length} exceeds maximum of 255`)
    }
    if (topic.length > 255) {
      throw new Error(`topic length ${topic.length} exceeds maximum of 255`)
    }
    if (userEvent.length > 255) {
      throw new Error(`userEvent length ${userEvent.length} exceeds maximum of 255`)
    }
    if (metadata.length > 255) {
      throw new Error(`metadata length ${metadata.length} exceeds maximum of 255`)
    }

    const metaLength =
      this.USER_BROADCAST_PUSH_META_LENGTH +
      joinRef.length +
      ref.length +
      topic.length +
      userEvent.length +
      metadata.length

    const header = new ArrayBuffer(this.HEADER_LENGTH + metaLength)
    let view = new DataView(header)
    let offset = 0

    view.setUint8(offset++, this.KINDS.userBroadcastPush) // kind
    view.setUint8(offset++, joinRef.length)
    view.setUint8(offset++, ref.length)
    view.setUint8(offset++, topic.length)
    view.setUint8(offset++, userEvent.length)
    view.setUint8(offset++, metadata.length)
    view.setUint8(offset++, encodingType)
    Array.from(joinRef, (char) => view.setUint8(offset++, char.charCodeAt(0)))
    Array.from(ref, (char) => view.setUint8(offset++, char.charCodeAt(0)))
    Array.from(topic, (char) => view.setUint8(offset++, char.charCodeAt(0)))
    Array.from(userEvent, (char) => view.setUint8(offset++, char.charCodeAt(0)))
    Array.from(metadata, (char) => view.setUint8(offset++, char.charCodeAt(0)))

    var combined = new Uint8Array(header.byteLength + encodedPayload.byteLength)
    combined.set(new Uint8Array(header), 0)
    combined.set(new Uint8Array(encodedPayload), header.byteLength)

    return combined.buffer
  }

  decode(rawPayload: ArrayBuffer | string, callback: Function) {
    if (this._isArrayBuffer(rawPayload)) {
      let result = this._binaryDecode(rawPayload as ArrayBuffer)
      return callback(result)
    }

    if (typeof rawPayload === 'string') {
      const jsonPayload = JSON.parse(rawPayload)
      const [join_ref, ref, topic, event, payload] = jsonPayload
      return callback({ join_ref, ref, topic, event, payload })
    }

    return callback({})
  }

  private _binaryDecode(buffer: ArrayBuffer) {
    const view = new DataView(buffer)
    const kind = view.getUint8(0)
    const decoder = new TextDecoder()
    switch (kind) {
      case this.KINDS.userBroadcast:
        return this._decodeUserBroadcast(buffer, view, decoder)
    }
  }

  private _decodeUserBroadcast(
    buffer: ArrayBuffer,
    view: DataView,
    decoder: TextDecoder
  ): {
    join_ref: null
    ref: null
    topic: string
    event: string
    payload: { [key: string]: any }
  } {
    const topicSize = view.getUint8(1)
    const userEventSize = view.getUint8(2)
    const metadataSize = view.getUint8(3)
    const payloadEncoding = view.getUint8(4)

    let offset = this.HEADER_LENGTH + 4
    const topic = decoder.decode(buffer.slice(offset, offset + topicSize))
    offset = offset + topicSize
    const userEvent = decoder.decode(buffer.slice(offset, offset + userEventSize))
    offset = offset + userEventSize
    const metadata = decoder.decode(buffer.slice(offset, offset + metadataSize))
    offset = offset + metadataSize

    const payload = buffer.slice(offset, buffer.byteLength)
    const parsedPayload =
      payloadEncoding === this.JSON_ENCODING ? JSON.parse(decoder.decode(payload)) : payload

    const data: { [key: string]: any } = {
      type: this.BROADCAST_EVENT,
      event: userEvent,
      payload: parsedPayload,
    }

    // Metadata is optional and always JSON encoded
    if (metadataSize > 0) {
      data['meta'] = JSON.parse(metadata)
    }

    return { join_ref: null, ref: null, topic: topic, event: this.BROADCAST_EVENT, payload: data }
  }

  private _isArrayBuffer(buffer: any): boolean {
    return buffer instanceof ArrayBuffer || buffer?.constructor?.name === 'ArrayBuffer'
  }

  private _pick(obj: Record<string, any> | null | undefined, keys: string[]): Record<string, any> {
    if (!obj || typeof obj !== 'object') {
      return {}
    }
    return Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
  }
}
