// Mostly adopted from: https://github.com/yjs/y-websocket

import * as awarenessProtocol from 'y-protocols/awareness.js'
import * as syncProtocol from 'y-protocols/sync.js'
import { Doc } from 'yjs'

import { decoding, encoding, map } from 'lib0'

import { readFile, stat, writeFile } from 'fs/promises'
import { fileURLToPath } from 'url'
import path from 'path'

export async function upgrade (sessions, request, reqPath) {
  // todo: check auth and project

  // todo: check dir traversal
  request.editorFile = `${path.dirname(fileURLToPath(import.meta.url))}/../../files/${reqPath[3]}`

  return true
}

export async function connection (ws, req) {
  ws.binaryType = 'arraybuffer'

  // get doc, initialize if it does not exist yet
  const doc = getYDoc(req.editorFile)
  doc.conns.set(ws, new Set())

  // listen and reply to events
  ws.on('message', message => messageListener(ws, doc, new Uint8Array(message)))

  // Check if connection is still alive
  let pongReceived = true
  const pingInterval = setInterval(() => {
    if (!pongReceived) {
      if (doc.conns.has(ws)) {
        closeConn(doc, ws)
      }
      clearInterval(pingInterval)
    } else if (doc.conns.has(ws)) {
      pongReceived = false
      try {
        ws.ping()
      } catch (e) {
        closeConn(doc, ws)
        clearInterval(pingInterval)
      }
    }
  }, pingTimeout)

  ws.on('close', () => {
    closeConn(doc, ws)
    clearInterval(pingInterval)
  })

  ws.on('pong', () => {
    pongReceived = true
  })

  // put the following in a variables in a block so the interval handlers don't keep in in
  // scope
  {
    // send sync step 1
    const encoder = encoding.createEncoder()
    encoding.writeVarUint(encoder, messageSync)
    syncProtocol.writeSyncStep1(encoder, doc)
    send(doc, ws, encoding.toUint8Array(encoder))
    const awarenessStates = doc.awareness.getStates()
    if (awarenessStates.size > 0) {
      const encoder = encoding.createEncoder()
      encoding.writeVarUint(encoder, messageAwareness)
      encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())))
      send(doc, ws, encoding.toUint8Array(encoder))
    }
  }

  return true
}

const messageSync = 0
const messageAwareness = 1

const wsReadyStateConnecting = 0
const wsReadyStateOpen = 1
const wsReadyStateClosing = 2
const wsReadyStateClosed = 3

const pingTimeout = 30000

const docs = new Map()

class WSSharedDoc extends Doc {
  /**
   * @param {string} name
   */
  constructor (name) {
    super({ gc: true })
    this.name = name

    this.conns = new Map()

    this.awareness = new awarenessProtocol.Awareness(this)

    this.awareness.setLocalState(null)

    const awarenessChangeHandler = ({ added, updated, removed }, conn) => {
      const changedClients = added.concat(updated, removed)
      if (conn !== null) {
        const connControlledIDs = this.conns.get(conn)

        if (connControlledIDs !== undefined) {
          added.forEach(clientID => connControlledIDs.add(clientID))
          removed.forEach(clientID => connControlledIDs.delete(clientID))
        }
      }
      // broadcast awareness update
      const encoder = encoding.createEncoder()
      encoding.writeVarUint(encoder, messageAwareness)
      encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients))
      const buff = encoding.toUint8Array(encoder)
      this.conns.forEach((_, c) => {
        send(this, c, buff)
      })
    }

    this.awareness.on('update', awarenessChangeHandler)

    this.on('update', updateHandler)
  }
}

const updateHandler = (update, origin, doc) => {
  const encoder = encoding.createEncoder()
  encoding.writeVarUint(encoder, messageSync)
  syncProtocol.writeUpdate(encoder, update)

  const message = encoding.toUint8Array(encoder)
  doc.conns.forEach((_, conn) => send(doc, conn, message))

  writeUpdate(doc)
}

const debounce = (func, wait, immediate) => {
  let timeout
  return (...args) => {
    let context = this
    let later = () => {
      timeout = null
      if (!immediate) func.apply(context, args)
    }
    let callNow = immediate && !timeout
    clearTimeout(timeout)
    timeout = setTimeout(later, wait)
    if (callNow) func.apply(context, args)
  }
}

const writeUpdate = debounce(async doc => {
  await writeFile(doc.name, doc.getText('monaco').toJSON())
}, 2000)

const getYDoc = file => map.setIfUndefined(docs, file, () => {

  const doc = new WSSharedDoc(file)

  doc.gc = true
  docs.set(file, doc)

  stat(file)
    .then(() => {
      return readFile(file, { encoding: 'utf8' })
    })
    .then(content => {
      const text = doc.getText('monaco')
      text.insert(0, content)
    })
    .catch(() => {
      // new file
    })

  return doc
})

const messageListener = (conn, doc, message) => {
  try {
    const encoder = encoding.createEncoder()
    const decoder = decoding.createDecoder(message)
    const messageType = decoding.readVarUint(decoder)
    switch (messageType) {
      case messageSync:
        encoding.writeVarUint(encoder, messageSync)
        syncProtocol.readSyncMessage(decoder, encoder, doc, null)
        if (encoding.length(encoder) > 1) {
          send(doc, conn, encoding.toUint8Array(encoder))
        }
        break
      case messageAwareness: {
        awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn)
        break
      }
    }
  } catch (err) {
    console.error(err)
    doc.emit('error', [err])
  }
}

const send = (doc, conn, m) => {
  if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) {
    console.log(conn.readyState)
    closeConn(doc, conn)
  }
  try {
    conn.send(m, err => { err != null && closeConn(doc, conn) })
  } catch (e) {
    closeConn(doc, conn)
  }
}

const closeConn = (doc, conn) => {
  if (doc.conns.has(conn)) {
    const controlledIds = doc.conns.get(conn)
    doc.conns.delete(conn)
    awarenessProtocol.removeAwarenessStates(doc.awareness, Array.from(controlledIds), null)
    docs.delete(doc.name)
  }

  conn.close()
}
