/**
 * @module types
 */

import { Type } from '../structs/Type.js'
import { ItemJSON } from '../structs/ItemJSON.js'
import { ItemString } from '../structs/ItemString.js'
import * as stringify from '../utils/structStringify.js'
import { YEvent } from '../utils/YEvent.js'
import { Transaction } from '../utils/Transaction.js' // eslint-disable-line
import { Item } from '../structs/Item.js' // eslint-disable-line
import { ItemBinary } from '../structs/ItemBinary.js'
import { isVisible } from '../utils/snapshot.js'

/**
 * Event that describes the changes on a YArray
 */
export class YArrayEvent extends YEvent {
  /**
   * @param {YArray} yarray The changed type
   * @param {Boolean} remote Whether the changed was caused by a remote peer
   * @param {Transaction} transaction The transaction object
   */
  constructor (yarray, remote, transaction) {
    super(yarray)
    this.remote = remote
    this._transaction = transaction
    this._addedElements = null
    this._removedElements = null
  }

  /**
   * Child elements that were added in this transaction.
   *
   * @return {Set}
   */
  get addedElements () {
    if (this._addedElements === null) {
      const target = this.target
      const transaction = this._transaction
      const addedElements = new Set()
      transaction.newTypes.forEach(type => {
        if (type._parent === target && !transaction.deletedStructs.has(type)) {
          addedElements.add(type)
        }
      })
      this._addedElements = addedElements
    }
    return this._addedElements
  }

  /**
   * Child elements that were removed in this transaction.
   *
   * @return {Set}
   */
  get removedElements () {
    if (this._removedElements === null) {
      const target = this.target
      const transaction = this._transaction
      const removedElements = new Set()
      transaction.deletedStructs.forEach(struct => {
        if (struct._parent === target && !transaction.newTypes.has(struct)) {
          removedElements.add(struct)
        }
      })
      this._removedElements = removedElements
    }
    return this._removedElements
  }
}

/**
 * A shared Array implementation.
 */
export class YArray extends Type {
  constructor () {
    super()
    this.length = 0
  }
  /**
   * Creates YArray Event and calls observers.
   *
   * @private
   */
  _callObserver (transaction, parentSubs, remote) {
    this._callEventHandler(transaction, new YArrayEvent(this, remote, transaction))
  }

  /**
   * Returns the i-th element from a YArray.
   *
   * @param {number} index The index of the element to return from the YArray
   * @return {any}
   */
  get (index) {
    let n = this._start
    while (n !== null) {
      if (!n._deleted && n._countable) {
        if (index < n._length) {
          switch (n.constructor) {
            case ItemJSON:
            case ItemString:
              return n._content[index]
            default:
              return n
          }
        }
        index -= n._length
      }
      n = n._right
    }
  }

  /**
   * Transforms this YArray to a JavaScript Array.
   *
   * @param {Object} [snapshot]
   * @return {Array}
   */
  toArray (snapshot) {
    return this.map(c => c, snapshot)
  }

  /**
   * Transforms this Shared Type to a JSON object.
   *
   * @return {Array}
   */
  toJSON () {
    return this.map(c => {
      if (c instanceof Type) {
        return c.toJSON()
      }
      return c
    })
  }

  /**
   * Returns an Array with the result of calling a provided function on every
   * element of this YArray.
   *
   * @param {Function} f Function that produces an element of the new Array
   * @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
   * @return {Array} A new array with each element being the result of the
   *                 callback function
   */
  map (f, snapshot) {
    const res = []
    this.forEach((c, i) => {
      res.push(f(c, i, this))
    }, snapshot)
    return res
  }

  /**
   * Executes a provided function on once on overy element of this YArray.
   *
   * @param {Function} f A function to execute on every element of this YArray.
   * @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
   */
  forEach (f, snapshot) {
    let index = 0
    let n = this._start
    while (n !== null) {
      if (isVisible(n, snapshot) && n._countable) {
        if (n instanceof Type) {
          f(n, index++, this)
        } else if (n.constructor === ItemBinary) {
          f(n._content, index++, this)
        } else {
          const content = n._content
          const contentLen = content.length
          for (let i = 0; i < contentLen; i++) {
            index++
            f(content[i], index, this)
          }
        }
      }
      n = n._right
    }
  }

  [Symbol.iterator] () {
    return {
      next: function () {
        while (this._item !== null && (this._item._deleted || this._item._length <= this._itemElement)) {
          // item is deleted or itemElement does not exist (is deleted)
          this._item = this._item._right
          this._itemElement = 0
        }
        if (this._item === null) {
          return {
            done: true
          }
        }
        let content
        if (this._item instanceof Type) {
          content = this._item
        } else {
          content = this._item._content[this._itemElement++]
        }
        return {
          value: content,
          done: false
        }
      },
      _item: this._start,
      _itemElement: 0,
      _count: 0
    }
  }

  /**
   * Deletes elements starting from an index.
   *
   * @param {number} index Index at which to start deleting elements
   * @param {number} length The number of elements to remove. Defaults to 1.
   */
  delete (index, length = 1) {
    this._y.transact(() => {
      let item = this._start
      let count = 0
      while (item !== null && length > 0) {
        if (!item._deleted && item._countable) {
          if (count <= index && index < count + item._length) {
            const diffDel = index - count
            item = item._splitAt(this._y, diffDel)
            item._splitAt(this._y, length)
            length -= item._length
            item._delete(this._y)
            count += diffDel
          } else {
            count += item._length
          }
        }
        item = item._right
      }
    })
    if (length > 0) {
      throw new Error('Delete exceeds the range of the YArray')
    }
  }

  /**
   * Inserts content after an element container.
   *
   * @private
   * @param {Item} left The element container to use as a reference.
   * @param {Array<number|string|Object|ArrayBuffer>} content The Array of content to insert (see {@see insert})
   */
  insertAfter (left, content) {
    this._transact(y => {
      let right
      if (left === null) {
        right = this._start
      } else {
        right = left._right
      }
      let prevJsonIns = null
      for (let i = 0; i < content.length; i++) {
        let c = content[i]
        if (typeof c === 'function') {
          c = new c() // eslint-disable-line new-cap
        }
        if (c instanceof Type) {
          if (prevJsonIns !== null) {
            if (y !== null) {
              prevJsonIns._integrate(y)
            }
            left = prevJsonIns
            prevJsonIns = null
          }
          c._origin = left
          c._left = left
          c._right = right
          c._right_origin = right
          c._parent = this
          if (y !== null) {
            c._integrate(y)
          } else if (left === null) {
            this._start = c
          } else {
            left._right = c
          }
          left = c
        } else if (c.constructor === ArrayBuffer) {
          if (prevJsonIns !== null) {
            if (y !== null) {
              prevJsonIns._integrate(y)
            }
            left = prevJsonIns
            prevJsonIns = null
          }
          const itemBinary = new ItemBinary()
          itemBinary._origin = left
          itemBinary._left = left
          itemBinary._right = right
          itemBinary._right_origin = right
          itemBinary._parent = this
          itemBinary._content = c
          if (y !== null) {
            itemBinary._integrate(y)
          } else if (left === null) {
            this._start = itemBinary
          } else {
            left._right = itemBinary
          }
          left = itemBinary
        } else {
          if (prevJsonIns === null) {
            prevJsonIns = new ItemJSON()
            prevJsonIns._origin = left
            prevJsonIns._left = left
            prevJsonIns._right = right
            prevJsonIns._right_origin = right
            prevJsonIns._parent = this
            prevJsonIns._content = []
          }
          prevJsonIns._content.push(c)
        }
      }
      if (prevJsonIns !== null) {
        if (y !== null) {
          prevJsonIns._integrate(y)
        } else if (prevJsonIns._left === null) {
          this._start = prevJsonIns
        } else {
          left._right = prevJsonIns
        }
      }
    })
    return content
  }

  /**
   * Inserts new content at an index.
   *
   * Important: This function expects an array of content. Not just a content
   * object. The reason for this "weirdness" is that inserting several elements
   * is very efficient when it is done as a single operation.
   *
   * @example
   *  // Insert character 'a' at position 0
   *  yarray.insert(0, ['a'])
   *  // Insert numbers 1, 2 at position 1
   *  yarray.insert(2, [1, 2])
   *
   * @param {number} index The index to insert content at.
   * @param {Array<number|string|ArrayBuffer|Type>} content The array of content
   */
  insert (index, content) {
    this._transact(() => {
      let left = null
      let right = this._start
      let count = 0
      const y = this._y
      while (right !== null) {
        const rightLen = right._deleted ? 0 : (right._length - 1)
        if (count <= index && index <= count + rightLen) {
          const splitDiff = index - count
          right = right._splitAt(y, splitDiff)
          left = right._left
          count += splitDiff
          break
        }
        if (!right._deleted) {
          count += right._length
        }
        left = right
        right = right._right
      }
      if (index > count) {
        throw new Error('Index exceeds array range!')
      }
      this.insertAfter(left, content)
    })
  }

  /**
   * Appends content to this YArray.
   *
   * @param {Array<number|string|ArrayBuffer|Type>} content Array of content to append.
   */
  push (content) {
    let n = this._start
    let lastUndeleted = null
    while (n !== null) {
      if (!n._deleted) {
        lastUndeleted = n
      }
      n = n._right
    }
    this.insertAfter(lastUndeleted, content)
  }

  /**
   * Transform this YXml Type to a readable format.
   * Useful for logging as all Items and Delete implement this method.
   *
   * @private
   */
  _logString () {
    return stringify.logItemHelper('YArray', this, `start:${stringify.stringifyItemID(this._start)}"`)
  }
}