/**
 * @module bindings/prosemirror
 */

import { BindMapping } from '../utils/BindMapping.mjs'
import { YText } from '../types/YText.mjs' // eslint-disable-line
import { YXmlElement, YXmlFragment } from '../types/YXmlElement.mjs' // eslint-disable-line
import { createMutex } from '../lib/mutex.mjs'
import * as PModel from 'prosemirror-model'
import { EditorView,  Decoration, DecorationSet } from 'prosemirror-view' // eslint-disable-line
import { Plugin, PluginKey, EditorState } from 'prosemirror-state' // eslint-disable-line

/**
 * @typedef {BindMapping<YText | YXmlElement, PModel.Node>} ProsemirrorMapping
 */

/**
 * The unique prosemirror plugin key for prosemirrorPlugin.
 *
 * @public
 */
export const prosemirrorPluginKey = new PluginKey('yjs')

/**
 * This plugin listens to changes in prosemirror view and keeps yXmlState and view in sync.
 *
 * This plugin also keeps references to the type and the shared document so other plugins can access it.
 * @param {YXmlFragment} yXmlFragment
 * @return {Plugin} Returns a prosemirror plugin that binds to this type
 */
export const prosemirrorPlugin = yXmlFragment => {
  const pluginState = {
    type: yXmlFragment,
    y: yXmlFragment._y,
    binding: null
  }
  const plugin = new Plugin({
    key: prosemirrorPluginKey,
    state: {
      init: (initargs, state) => {
        return pluginState
      },
      apply: (tr, pluginState) => {
        return pluginState
      }
    },
    view: view => {
      const binding = new ProsemirrorBinding(yXmlFragment, view)
      pluginState.binding = binding
      return {
        update: () => {
          binding._prosemirrorChanged()
        },
        destroy: () => {
          binding.destroy()
        }
      }
    }
  })
  return plugin
}

/**
 * The unique prosemirror plugin key for cursorPlugin.
 *
 * @public
 */
export const cursorPluginKey = new PluginKey('yjs-cursor')

/**
 * A prosemirror plugin that listens to awareness information on Yjs.
 * This requires that a `prosemirrorPlugin` is also bound to the prosemirror.
 *
 * @public
 */
export const cursorPlugin = new Plugin({
  key: cursorPluginKey,
  props: {
    decorations: state => {
      const y = prosemirrorPluginKey.getState(state).y
      const awareness = y.getAwarenessInfo()
      const decorations = []
      awareness.forEach((state, userID) => {
        if (state.cursor != null) {
          const username = `User: ${userID}`
          decorations.push(Decoration.widget(state.cursor.from, () => {
            const cursor = document.createElement('span')
            cursor.classList.add('ProseMirror-yjs-cursor')
            const user = document.createElement('div')
            user.insertBefore(document.createTextNode(username), null)
            cursor.insertBefore(user, null)
            return cursor
          }, { key: username }))
          decorations.push(Decoration.inline(state.cursor.from, state.cursor.to, { style: 'background-color: #ffa50070' }))
        }
      })
      return DecorationSet.create(state.doc, decorations)
    }
  },
  view: view => {
    const y = prosemirrorPluginKey.getState(view.state).y
    const awarenessListener = () => {
      view.updateState(view.state)
    }
    y.on('awareness', awarenessListener)
    return {
      update: () => {
        const y = prosemirrorPluginKey.getState(view.state).y
        const from = view.state.selection.from
        const to = view.state.selection.to
        const current = y.getLocalAwarenessInfo()
        if (current.cursor == null || current.cursor.to !== to || current.cursor.from !== from) {
          y.setAwarenessField('cursor', {
            from, to
          })
        }
      },
      destroy: () => {
        const y = prosemirrorPluginKey.getState(view.state).y
        y.setAwarenessField('cursor', null)
        y.off('awareness', awarenessListener)
      }
    }
  }
})

/**
 * Binding for prosemirror.
 *
 * @protected
 */
export class ProsemirrorBinding {
  /**
   * @param {YXmlFragment} yXmlFragment The bind source
   * @param {EditorView} prosemirrorView The target binding
   */
  constructor (yXmlFragment, prosemirrorView) {
    this.type = yXmlFragment
    this.prosemirrorView = prosemirrorView
    this.mux = createMutex()
    /**
     * @type {ProsemirrorMapping}
     */
    this.mapping = new BindMapping()
    this._observeFunction = this._typeChanged.bind(this)
    yXmlFragment.observeDeep(this._observeFunction)
  }
  _typeChanged (events) {
    if (events.length === 0) {
      return
    }
    this.mux(() => {
      events.forEach(event => {
        // recompute node for each parent
        // except main node, compute main node in the end
        let target = event.target
        if (target !== this.type) {
          do {
            if (target.constructor === YXmlElement) {
              createNodeFromYElement(target, this.prosemirrorView.state.schema, this.mapping)
            }
            target = target._parent
          } while (target._parent !== this.type)
        }
      })
      const fragmentContent = this.type.toArray().map(t => createNodeIfNotExists(t, this.prosemirrorView.state.schema, this.mapping))
      const tr = this.prosemirrorView.state.tr.replace(0, this.prosemirrorView.state.doc.content.size, new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0))
      this.prosemirrorView.updateState(this.prosemirrorView.state.apply(tr))
    })
  }
  _prosemirrorChanged () {
    this.mux(() => {
      updateYFragment(this.type, this.prosemirrorView.state, this.mapping)
    })
  }
  destroy () {
    this.type.unobserveDeep(this._observeFunction)
  }
}

/**
 * @private
 * @param {Y.XmlElement} el
 * @param {PModel.Schema} schema
 * @param {ProsemirrorMapping} mapping
 * @return {PModel.Node}
 */
export const createNodeIfNotExists = (el, schema, mapping) => {
  const node = mapping.getY(el)
  if (node === undefined) {
    return createNodeFromYElement(el, schema, mapping)
  }
  return node
}

/**
 * @private
 * @param {Y.XmlElement} el
 * @param {PModel.Schema} schema
 * @param {ProsemirrorMapping} mapping
 * @return {PModel.Node}
 */
export const createNodeFromYElement = (el, schema, mapping) => {
  const children = []
  el.toArray().forEach(type => {
    if (type.constructor === YXmlElement) {
      children.push(createNodeIfNotExists(type, schema, mapping))
    } else {
      children.concat(createTextNodesFromYText(type, schema, mapping)).forEach(textchild => children.push(textchild))
    }
  })
  const node = schema.node(el.nodeName.toLowerCase(), el.getAttributes(), el.toArray().map(t => createNodeIfNotExists(t, schema, mapping)))
  mapping.bind(el, node)
  return node
}

/**
 * @private
 * @param {Y.Text} text
 * @param {PModel.Schema} schema
 * @param {ProsemirrorMapping} mapping
 * @return {Array<PModel.Node>}
 */
export const createTextNodesFromYText = (text, schema, mapping) => {
  const nodes = []
  const deltas = text.toDelta()
  for (let i = 0; i < deltas.length; i++) {
    const delta = deltas[i]
    const marks = []
    for (let markName in delta.attributes) {
      marks.push(schema.mark(markName, delta.attributes[markName]))
    }
    nodes.push(schema.text(delta.insert, marks))
  }
  if (nodes.length > 0) {
    mapping.bind(text, nodes[0]) // only map to first child, all following children are also considered bound to this type
  }
  return nodes
}

/**
 * @private
 * @param {PModel.Node} node
 * @param {ProsemirrorMapping} mapping
 * @return {YXmlElement | YText}
 */
export const createTypeFromNode = (node, mapping) => {
  let type
  if (node.isText) {
    type = new YText()
    const attrs = {}
    node.marks.forEach(mark => { attrs[mark.type.name] = mark.attrs })
    type.insert(0, node.text, attrs)
  } else {
    type = new YXmlElement(node.type.name)
    for (let key in node.attrs) {
      type.setAttribute(key, node.attrs[key])
    }
    type.insert(0, node.content.content.map(node => createTypeFromNode(node, mapping)))
  }
  mapping.bind(type, node)
  return type
}

/**
 * @private
 * @param {YXmlFragment} yDomFragment
 * @param {EditorState} state
 * @param {BindMapping} mapping
 */
const updateYFragment = (yDomFragment, state, mapping) => {
  const pChildCnt = state.doc.content.childCount
  const yChildren = yDomFragment.toArray()
  const yChildCnt = yChildren.length
  const minCnt = pChildCnt < yChildCnt ? pChildCnt : yChildCnt
  let left = 0
  let right = 0
  // find number of matching elements from left
  for (;left < minCnt; left++) {
    if (state.doc.content.child(left) !== mapping.getY(yChildren[left])) {
      break
    }
  }
  // find number of matching elements from right
  for (;right < minCnt; right++) {
    if (state.doc.content.child(pChildCnt - right - 1) !== mapping.getY(yChildren[yChildCnt - right - 1])) {
      break
    }
  }
  if (left + right > pChildCnt) {
    // nothing changed
    return
  }
  yDomFragment._y.transact(() => {
    // now update y to match editor state
    yDomFragment.delete(left, yChildCnt - left - right)
    yDomFragment.insert(left, state.doc.content.content.slice(left, pChildCnt - right).map(node => createTypeFromNode(node, mapping)))
  })
}