/**
 * @module types
 */

import { ItemEmbed } from '../structs/ItemEmbed.js'
import { ItemString } from '../structs/ItemString.js'
import { ItemFormat } from '../structs/ItemFormat.js'
import * as stringify from '../utils/structStringify.js'
import { YArrayEvent, YArray } from './YArray.js'
import { isVisible } from '../utils/snapshot.js'

/**
 * @private
 */
const integrateItem = (item, parent, y, left, right) => {
  item._origin = left
  item._left = left
  item._right = right
  item._right_origin = right
  item._parent = parent
  if (y !== null) {
    item._integrate(y)
  } else if (left === null) {
    parent._start = item
  } else {
    left._right = item
  }
}

/**
 * @private
 */
const findNextPosition = (currentAttributes, parent, left, right, count) => {
  while (right !== null && count > 0) {
    switch (right.constructor) {
      case ItemEmbed:
      case ItemString:
        const rightLen = right._deleted ? 0 : (right._length - 1)
        if (count <= rightLen) {
          right = right._splitAt(parent._y, count)
          left = right._left
          return [left, right, currentAttributes]
        }
        if (right._deleted === false) {
          count -= right._length
        }
        break
      case ItemFormat:
        if (right._deleted === false) {
          updateCurrentAttributes(currentAttributes, right)
        }
        break
    }
    left = right
    right = right._right
  }
  return [left, right, currentAttributes]
}

/**
 * @private
 */
const findPosition = (parent, index) => {
  let currentAttributes = new Map()
  let left = null
  let right = parent._start
  return findNextPosition(currentAttributes, parent, left, right, index)
}

/**
 * Negate applied formats
 *
 * @private
 */
const insertNegatedAttributes = (y, parent, left, right, negatedAttributes) => {
  // check if we really need to remove attributes
  while (
    right !== null && (
      right._deleted === true || (
        right.constructor === ItemFormat &&
        (negatedAttributes.get(right.key) === right.value)
      )
    )
  ) {
    if (right._deleted === false) {
      negatedAttributes.delete(right.key)
    }
    left = right
    right = right._right
  }
  for (let [key, val] of negatedAttributes) {
    let format = new ItemFormat()
    format.key = key
    format.value = val
    integrateItem(format, parent, y, left, right)
    left = format
  }
  return [left, right]
}

/**
 * @private
 */
const updateCurrentAttributes = (currentAttributes, item) => {
  const value = item.value
  const key = item.key
  if (value === null) {
    currentAttributes.delete(key)
  } else {
    currentAttributes.set(key, value)
  }
}

/**
 * @private
 */
const minimizeAttributeChanges = (left, right, currentAttributes, attributes) => {
  // go right while attributes[right.key] === right.value (or right is deleted)
  while (true) {
    if (right === null) {
      break
    } else if (right._deleted === true) {
      // continue
    } else if (right.constructor === ItemFormat && (attributes[right.key] || null) === right.value) {
      // found a format, update currentAttributes and continue
      updateCurrentAttributes(currentAttributes, right)
    } else {
      break
    }
    left = right
    right = right._right
  }
  return [left, right]
}

/**
 * @private
 */
const insertAttributes = (y, parent, left, right, attributes, currentAttributes) => {
  const negatedAttributes = new Map()
  // insert format-start items
  for (let key in attributes) {
    const val = attributes[key]
    const currentVal = currentAttributes.get(key)
    if (currentVal !== val) {
      // save negated attribute (set null if currentVal undefined)
      negatedAttributes.set(key, currentVal || null)
      let format = new ItemFormat()
      format.key = key
      format.value = val
      integrateItem(format, parent, y, left, right)
      left = format
    }
  }
  return [left, right, negatedAttributes]
}

/**
 * @private
 */
const insertText = (y, text, parent, left, right, currentAttributes, attributes) => {
  for (let [key] of currentAttributes) {
    if (attributes[key] === undefined) {
      attributes[key] = null
    }
  }
  [left, right] = minimizeAttributeChanges(left, right, currentAttributes, attributes)
  let negatedAttributes
  [left, right, negatedAttributes] = insertAttributes(y, parent, left, right, attributes, currentAttributes)
  // insert content
  let item
  if (text.constructor === String) {
    item = new ItemString()
    item._content = text
  } else {
    item = new ItemEmbed()
    item.embed = text
  }
  integrateItem(item, parent, y, left, right)
  left = item
  return insertNegatedAttributes(y, parent, left, right, negatedAttributes)
}

/**
 * @private
 */
const formatText = (y, length, parent, left, right, currentAttributes, attributes) => {
  [left, right] = minimizeAttributeChanges(left, right, currentAttributes, attributes)
  let negatedAttributes
  [left, right, negatedAttributes] = insertAttributes(y, parent, left, right, attributes, currentAttributes)
  // iterate until first non-format or null is found
  // delete all formats with attributes[format.key] != null
  while (length > 0 && right !== null) {
    if (right._deleted === false) {
      switch (right.constructor) {
        case ItemFormat:
          const attr = attributes[right.key]
          if (attr !== undefined) {
            if (attr === right.value) {
              negatedAttributes.delete(right.key)
            } else {
              negatedAttributes.set(right.key, right.value)
            }
            right._delete(y)
          }
          updateCurrentAttributes(currentAttributes, right)
          break
        case ItemEmbed:
        case ItemString:
          right._splitAt(y, length)
          length -= right._length
          break
      }
    }
    left = right
    right = right._right
  }
  return insertNegatedAttributes(y, parent, left, right, negatedAttributes)
}

/**
 * @private
 */
const deleteText = (y, length, parent, left, right, currentAttributes) => {
  while (length > 0 && right !== null) {
    if (right._deleted === false) {
      switch (right.constructor) {
        case ItemFormat:
          updateCurrentAttributes(currentAttributes, right)
          break
        case ItemEmbed:
        case ItemString:
          right._splitAt(y, length)
          length -= right._length
          right._delete(y)
          break
      }
    }
    left = right
    right = right._right
  }
  return [left, right]
}

// TODO: In the quill delta representation we should also use the format {ops:[..]}
/**
 * The Quill Delta format represents changes on a text document with
 * formatting information. For mor information visit {@link https://quilljs.com/docs/delta/|Quill Delta}
 *
 * @example
 *   {
 *     ops: [
 *       { insert: 'Gandalf', attributes: { bold: true } },
 *       { insert: ' the ' },
 *       { insert: 'Grey', attributes: { color: '#cccccc' } }
 *     ]
 *   }
 *
 * @typedef {Array<Object>} Delta
 */

/**
  * Attributes that can be assigned to a selection of text.
  *
  * @example
  *   {
  *     bold: true,
  *     font-size: '40px'
  *   }
  *
  * @typedef {Object} TextAttributes
  */

/**
 * Event that describes the changes on a YText type.
 *
 * @private
 */
class YTextEvent extends YArrayEvent {
  constructor (ytext, remote, transaction) {
    super(ytext, remote, transaction)
    this._delta = null
  }
  // TODO: Should put this in a separate function. toDelta shouldn't be included
  //       in every Yjs distribution
  /**
   * Compute the changes in the delta format.
   *
   * @return {Delta} A {@link https://quilljs.com/docs/delta/|Quill Delta}) that
   *                 represents the changes on the document.
   *
   * @public
   */
  get delta () {
    if (this._delta === null) {
      const y = this.target._y
      y.transact(() => {
        let item = this.target._start
        const delta = []
        const added = this.addedElements
        const removed = this.removedElements
        this._delta = delta
        let action = null
        let attributes = {} // counts added or removed new attributes for retain
        const currentAttributes = new Map() // saves all current attributes for insert
        const oldAttributes = new Map()
        let insert = ''
        let retain = 0
        let deleteLen = 0
        const addOp = function addOp () {
          if (action !== null) {
            /**
             * @type {any}
             */
            let op
            switch (action) {
              case 'delete':
                op = { delete: deleteLen }
                deleteLen = 0
                break
              case 'insert':
                op = { insert }
                if (currentAttributes.size > 0) {
                  op.attributes = {}
                  for (let [key, value] of currentAttributes) {
                    if (value !== null) {
                      op.attributes[key] = value
                    }
                  }
                }
                insert = ''
                break
              case 'retain':
                op = { retain }
                if (Object.keys(attributes).length > 0) {
                  op.attributes = {}
                  for (let key in attributes) {
                    op.attributes[key] = attributes[key]
                  }
                }
                retain = 0
                break
            }
            delta.push(op)
            action = null
          }
        }
        while (item !== null) {
          switch (item.constructor) {
            case ItemEmbed:
              if (added.has(item)) {
                addOp()
                action = 'insert'
                insert = item.embed
                addOp()
              } else if (removed.has(item)) {
                if (action !== 'delete') {
                  addOp()
                  action = 'delete'
                }
                deleteLen += 1
              } else if (item._deleted === false) {
                if (action !== 'retain') {
                  addOp()
                  action = 'retain'
                }
                retain += 1
              }
              break
            case ItemString:
              if (added.has(item)) {
                if (action !== 'insert') {
                  addOp()
                  action = 'insert'
                }
                insert += item._content
              } else if (removed.has(item)) {
                if (action !== 'delete') {
                  addOp()
                  action = 'delete'
                }
                deleteLen += item._length
              } else if (item._deleted === false) {
                if (action !== 'retain') {
                  addOp()
                  action = 'retain'
                }
                retain += item._length
              }
              break
            case ItemFormat:
              if (added.has(item)) {
                const curVal = currentAttributes.get(item.key) || null
                if (curVal !== item.value) {
                  if (action === 'retain') {
                    addOp()
                  }
                  if (item.value === (oldAttributes.get(item.key) || null)) {
                    delete attributes[item.key]
                  } else {
                    attributes[item.key] = item.value
                  }
                } else {
                  item._delete(y)
                }
              } else if (removed.has(item)) {
                oldAttributes.set(item.key, item.value)
                const curVal = currentAttributes.get(item.key) || null
                if (curVal !== item.value) {
                  if (action === 'retain') {
                    addOp()
                  }
                  attributes[item.key] = curVal
                }
              } else if (item._deleted === false) {
                oldAttributes.set(item.key, item.value)
                const attr = attributes[item.key]
                if (attr !== undefined) {
                  if (attr !== item.value) {
                    if (action === 'retain') {
                      addOp()
                    }
                    if (item.value === null) {
                      attributes[item.key] = item.value
                    } else {
                      delete attributes[item.key]
                    }
                  } else {
                    item._delete(y)
                  }
                }
              }
              if (item._deleted === false) {
                if (action === 'insert') {
                  addOp()
                }
                updateCurrentAttributes(currentAttributes, item)
              }
              break
          }
          item = item._right
        }
        addOp()
        while (this._delta.length > 0) {
          let lastOp = this._delta[this._delta.length - 1]
          if (lastOp.retain !== undefined && lastOp.attributes === undefined) {
            // retain delta's if they don't assign attributes
            this._delta.pop()
          } else {
            break
          }
        }
      })
    }
    return this._delta
  }
}

/**
 * Type that represents text with formatting information.
 *
 * This type replaces y-richtext as this implementation is able to handle
 * block formats (format information on a paragraph), embeds (complex elements
 * like pictures and videos), and text formats (**bold**, *italic*).
 */
export class YText extends YArray {
  /**
   * @param {String} [string] The initial value of the YText.
   */
  constructor (string) {
    super()
    if (typeof string === 'string') {
      const start = new ItemString()
      start._parent = this
      start._content = string
      this._start = start
    }
  }

  /**
   * Creates YMap Event and calls observers.
   *
   * @private
   */
  _callObserver (transaction, parentSubs, remote) {
    this._callEventHandler(transaction, new YTextEvent(this, remote, transaction))
  }

  toDom () {
    return document.createTextNode(this.toString())
  }

  /**
   * Returns the unformatted string representation of this YText type.
   *
   * @public
   */
  toString () {
    let str = ''
    /**
     * @type {any}
     */
    let n = this._start
    while (n !== null) {
      if (!n._deleted && n._countable) {
        str += n._content
      }
      n = n._right
    }
    return str
  }

  toDomString () {
    return this.toDelta().map(delta => {
      const nestedNodes = []
      for (let nodeName in delta.attributes) {
        const attrs = []
        for (let key in delta.attributes[nodeName]) {
          attrs.push({key, value: delta.attributes[nodeName][key]})
        }
        // sort attributes to get a unique order
        attrs.sort((a, b) => a.key < b.key ? -1 : 1)
        nestedNodes.push({ nodeName, attrs })
      }
      // sort node order to get a unique order
      nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
      // now convert to dom string
      let str = ''
      for (let i = 0; i < nestedNodes.length; i++) {
        const node = nestedNodes[i]
        str += `<${node.nodeName}`
        for (let j = 0; j < node.attrs.length; j++) {
          const attr = node.attrs[i]
          str += ` ${attr.key}="${attr.value}"`
        }
        str += '>'
      }
      str += delta.insert
      for (let i = nestedNodes.length - 1; i >= 0; i--) {
        str += `</${nestedNodes[i].nodeName}>`
      }
      return str
    })
  }

  /**
   * Apply a {@link Delta} on this shared YText type.
   *
   * @param {Delta} delta The changes to apply on this element.
   *
   * @public
   */
  applyDelta (delta) {
    this._transact(y => {
      let left = null
      let right = this._start
      const currentAttributes = new Map()
      for (let i = 0; i < delta.length; i++) {
        let op = delta[i]
        if (op.insert !== undefined) {
          ;[left, right] = insertText(y, op.insert, this, left, right, currentAttributes, op.attributes || {})
        } else if (op.retain !== undefined) {
          ;[left, right] = formatText(y, op.retain, this, left, right, currentAttributes, op.attributes || {})
        } else if (op.delete !== undefined) {
          ;[left, right] = deleteText(y, op.delete, this, left, right, currentAttributes)
        }
      }
    })
  }

  /**
   * Returns the Delta representation of this YText type.
   *
   * @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
   * @return {Delta} The Delta representation of this type.
   *
   * @public
   */
  toDelta (snapshot) {
    let ops = []
    let currentAttributes = new Map()
    let str = ''
    /**
     * @type {any}
     */
    let n = this._start
    function packStr () {
      if (str.length > 0) {
        // pack str with attributes to ops
        let attributes = {}
        let addAttributes = false
        for (let [key, value] of currentAttributes) {
          addAttributes = true
          attributes[key] = value
        }
        let op = { insert: str }
        if (addAttributes) {
          op.attributes = attributes
        }
        ops.push(op)
        str = ''
      }
    }
    while (n !== null) {
      if (isVisible(n, snapshot)) {
        switch (n.constructor) {
          case ItemString:
            str += n._content
            break
          case ItemFormat:
            packStr()
            updateCurrentAttributes(currentAttributes, n)
            break
        }
      }
      n = n._right
    }
    packStr()
    return ops
  }

  /**
   * Insert text at a given index.
   *
   * @param {number} index The index at which to start inserting.
   * @param {String} text The text to insert at the specified position.
   * @param {TextAttributes} attributes Optionally define some formatting
   *                                    information to apply on the inserted
   *                                    Text.
   * @public
   */
  insert (index, text, attributes = {}) {
    if (text.length <= 0) {
      return
    }
    this._transact(y => {
      let [left, right, currentAttributes] = findPosition(this, index)
      insertText(y, text, this, left, right, currentAttributes, attributes)
    })
  }

  /**
   * Inserts an embed at a index.
   *
   * @param {number} index The index to insert the embed at.
   * @param {Object} embed The Object that represents the embed.
   * @param {TextAttributes} attributes Attribute information to apply on the
   *                                    embed
   *
   * @public
   */
  insertEmbed (index, embed, attributes = {}) {
    if (embed.constructor !== Object) {
      throw new Error('Embed must be an Object')
    }
    this._transact(y => {
      let [left, right, currentAttributes] = findPosition(this, index)
      insertText(y, embed, this, left, right, currentAttributes, attributes)
    })
  }

  /**
   * Deletes text starting from an index.
   *
   * @param {number} index Index at which to start deleting.
   * @param {number} length The number of characters to remove. Defaults to 1.
   *
   * @public
   */
  delete (index, length) {
    if (length === 0) {
      return
    }
    this._transact(y => {
      let [left, right, currentAttributes] = findPosition(this, index)
      deleteText(y, length, this, left, right, currentAttributes)
    })
  }

  /**
   * Assigns properties to a range of text.
   *
   * @param {number} index The position where to start formatting.
   * @param {number} length The amount of characters to assign properties to.
   * @param {TextAttributes} attributes Attribute information to apply on the
   *                                    text.
   *
   * @public
   */
  format (index, length, attributes) {
    this._transact(y => {
      let [left, right, currentAttributes] = findPosition(this, index)
      if (right === null) {
        return
      }
      formatText(y, length, this, left, right, currentAttributes, attributes)
    })
  }
  // TODO: De-duplicate code. The following code is in every type.
  /**
   * Transform this YText to a readable format.
   * Useful for logging as all Items implement this method.
   *
   * @private
   */
  _logString () {
    return stringify.logItemHelper('YText', this)
  }
}