text: import cloneDeep from 'lodash.clonedeep';
  import isEqual from 'lodash.isequal';
  import merge from 'lodash.merge';
  import Delta, { AttributeMap, Op } from 'quill-delta';
  import { LeafBlot, Scope } from 'parchment';
  import katex from 'katex';
  import { Range } from './selection';
  import CursorBlot from '../blots/cursor';
  import Block, { BlockEmbed, bubbleFormats } from '../blots/block';
  import Break from '../blots/break';
  import TextBlot, { escapeText } from '../blots/text';
  import Formula from '../formats/formula';
  
  const ASCII = /^[ -~]*$/;
  
  class Editor {
    constructor(scroll) {
      this.scroll = scroll;
      this.delta = this.getDelta();
      window.katex = katex;
    }
  
    applyDelta(delta) {
      this.scroll.update();
      let scrollLength = this.scroll.length();
      this.scroll.batchStart();
      const normalizedDelta = normalizeDelta(delta);
      const deleteDelta = new Delta();
      normalizedDelta.reduce((index, op) => {
        const length = Op.length(op);
        let attributes = op.attributes || {};
        let addedNewline = false;
        if (op.insert != null) {
          deleteDelta.retain(length);
          if (typeof op.insert === 'string') {
            const text = op.insert;
            addedNewline =
              !text.endsWith('\n') &&
              (scrollLength <= index ||
                this.scroll.descendant(BlockEmbed, index)[0]);
            this.scroll.insertAt(index, text);
            const [line, offset] = this.scroll.line(index);
            let formats = merge({}, bubbleFormats(line));
            if (line instanceof Block) {
              const [leaf] = line.descendant(LeafBlot, offset);
              formats = merge(formats, bubbleFormats(leaf));
            }
            attributes = AttributeMap.diff(formats, attributes) || {};
          } else if (typeof op.insert === 'object') {
            const key = Object.keys(op.insert)[0]; // There should only be one key
            if (key == null) return index;
            addedNewline =
              this.scroll.query(key, Scope.INLINE) != null &&
              (scrollLength <= index ||
                this.scroll.descendant(BlockEmbed, index)[0]);
            this.scroll.insertAt(index, key, op.insert[key]);
          }
          scrollLength += length;
        } else {
          deleteDelta.push(op);
        }
        Object.keys(attributes).forEach(name => {
          this.scroll.formatAt(index, length, name, attributes[name]);
        });
        const addedLength = addedNewline ? 1 : 0;
        scrollLength += addedLength;
        deleteDelta.delete(addedLength);
        return index + length + addedLength;
      }, 0);
      deleteDelta.reduce((index, op) => {
        if (typeof op.delete === 'number') {
          this.scroll.deleteAt(index, op.delete);
          return index;
        }
        return index + Op.length(op);
      }, 0);
      this.scroll.batchEnd();
      this.scroll.optimize();
      return this.update(normalizedDelta);
    }
  
    deleteText(index, length) {
      this.scroll.deleteAt(index, length);
      return this.update(new Delta().retain(index).delete(length));
    }
  
    formatLine(index, length, formats = {}) {
      this.scroll.update();
      Object.keys(formats).forEach(format => {
        this.scroll.lines(index, Math.max(length, 1)).forEach(line => {
          line.format(format, formats[format]);
        });
      });
      this.scroll.optimize();
      const delta = new Delta().retain(index).retain(length, cloneDeep(formats));
      return this.update(delta);
    }
  
    formatText(index, length, formats = {}) {
      Object.keys(formats).forEach(format => {
        if (format === Formula.blotName) {
          const text = this.getText(index, length);
          this.deleteText(index, length);
          this.insertEmbed(index, format, text);
        } else this.scroll.formatAt(index, length, format, formats[format]);
      });
      const delta = new Delta().retain(index).retain(length, cloneDeep(formats));
      return this.update(delta);
    }
  
    getContents(index, length) {
      return this.delta.slice(index, index + length);
    }
  
    getDelta() {
      return this.scroll.lines().reduce((delta, line) => {
        return delta.concat(line.delta());
      }, new Delta());
    }
  
    getFormat(index, length = 0) {
      let lines = [];
      let leaves = [];
      if (length === 0) {
        this.scroll.path(index).forEach(path => {
          const [blot] = path;
          if (blot instanceof Block) {
            lines.push(blot);
          } else if (blot instanceof LeafBlot) {
            leaves.push(blot);
          }
        });
      } else {
        lines = this.scroll.lines(index, length);
        leaves = this.scroll.descendants(LeafBlot, index, length);
      }
      [lines, leaves] = [lines, leaves].map(blots => {
        if (blots.length === 0) return {};
        let formats = bubbleFormats(blots.shift());
        while (Object.keys(formats).length > 0) {
          const blot = blots.shift();
          if (blot == null) return formats;
          formats = combineFormats(bubbleFormats(blot), formats);
        }
        return formats;
      });
      return { ...lines, ...leaves };
    }
  
    getHTML(index, length) {
      const [line, lineOffset] = this.scroll.line(index);
      if (line.length() >= lineOffset + length) {
        return convertHTML(line, lineOffset, length, true);
      }
      return convertHTML(this.scroll, index, length, true);
    }
  
    getText(index, length) {
      return this.getContents(index, length)
        .filter(op => typeof op.insert === 'string')
        .map(op => op.insert)
        .join('');
    }
  
    insertEmbed(index, embed, value) {
      this.scroll.insertAt(index, embed, value);
      return this.update(new Delta().retain(index).insert({ [embed]: value }));
    }
  
    insertText(index, text, formats = {}) {
      text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
      this.scroll.insertAt(index, text);
      Object.keys(formats).forEach(format => {
        this.scroll.formatAt(index, text.length, format, formats[format]);
      });
      return this.update(
        new Delta().retain(index).insert(text, cloneDeep(formats)),
      );
    }
  
    isBlank() {
      if (this.scroll.children.length === 0) return true;
      if (this.scroll.children.length > 1) return false;
      const block = this.scroll.children.head;
      if (block.statics.blotName !== Block.blotName) return false;
      if (block.children.length > 1) return false;
      return block.children.head instanceof Break;
    }
  
    removeFormat(index, length) {
      const text = this.getText(index, length);
      const [line, offset] = this.scroll.line(index + length);
      let suffixLength = 0;
      let suffix = new Delta();
      if (line != null) {
        suffixLength = line.length() - offset;
        suffix = line
          .delta()
          .slice(offset, offset + suffixLength - 1)
          .insert('\n');
      }
      const contents = this.getContents(index, length + suffixLength);
      const diff = contents.diff(new Delta().insert(text).concat(suffix));
      const delta = new Delta().retain(index).concat(diff);
      return this.applyDelta(delta);
    }
  
    update(change, mutations = [], selectionInfo = undefined) {
      const oldDelta = this.delta;
      if (
        mutations.length === 1 &&
        mutations[0].type === 'characterData' &&
        mutations[0].target.data.match(ASCII) &&
        this.scroll.find(mutations[0].target)
      ) {
        // Optimization for character changes
        const textBlot = this.scroll.find(mutations[0].target);
        const formats = bubbleFormats(textBlot);
        const index = textBlot.offset(this.scroll);
        const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
        const oldText = new Delta().insert(oldValue);
        const newText = new Delta().insert(textBlot.value());
        const relativeSelectionInfo = selectionInfo && {
          oldRange: shiftRange(selectionInfo.oldRange, -index),
          newRange: shiftRange(selectionInfo.newRange, -index),
        };
        const diffDelta = new Delta()
          .retain(index)
          .concat(oldText.diff(newText, relativeSelectionInfo));
        change = diffDelta.reduce((delta, op) => {
          if (op.insert) {
            return delta.insert(op.insert, formats);
          }
          return delta.push(op);
        }, new Delta());
        this.delta = oldDelta.compose(change);
      } else {
        this.delta = this.getDelta();
        if (!change || !isEqual(oldDelta.compose(change), this.delta)) {
          change = oldDelta.diff(this.delta, selectionInfo);
        }
      }
      return change;
    }
  }
  
  function convertListHTML(items, lastIndent, types) {
    if (items.length === 0) {
      const [endTag] = getListType(types.pop());
      if (lastIndent <= 0) {
        return `</li></${endTag}>`;
      }
      return `</li></${endTag}>${convertListHTML([], lastIndent - 1, types)}`;
    }
    const [{ child, offset, length, indent, type }, ...rest] = items;
    const [tag, attribute] = getListType(type);
    if (indent > lastIndent) {
      types.push(type);
      if (indent === lastIndent + 1) {
        return `<${tag}><li${attribute}>${convertHTML(
          child,
          offset,
          length,
        )}${convertListHTML(rest, indent, types)}`;
      }
      return `<${tag}><li>${convertListHTML(items, lastIndent + 1, types)}`;
    }
    const previousType = types[types.length - 1];
    if (indent === lastIndent && type === previousType) {
      return `</li><li${attribute}>${convertHTML(
        child,
        offset,
        length,
      )}${convertListHTML(rest, indent, types)}`;
    }
    const [endTag] = getListType(types.pop());
    return `</li></${endTag}>${convertListHTML(items, lastIndent - 1, types)}`;
  }
  
  function convertHTML(blot, index, length, isRoot = false) {
    if (typeof blot.html === 'function') {
      return blot.html(index, length);
    }
    if (blot instanceof TextBlot) {
      return escapeText(blot.value().slice(index, index + length));
    }
    if (blot.children) {
      // TODO fix API
      if (blot.statics.blotName === 'list-container') {
        const items = [];
        blot.children.forEachAt(index, length, (child, offset, childLength) => {
          const formats = child.formats();
          items.push({
            child,
            offset,
            length: childLength,
            indent: formats.indent || 0,
            type: formats.list,
          });
        });
        return convertListHTML(items, -1, []);
      }
      const parts = [];
      blot.children.forEachAt(index, length, (child, offset, childLength) => {
        parts.push(convertHTML(child, offset, childLength));
      });
      if (isRoot || blot.statics.blotName === 'list') {
        return parts.join('');
      }
      const { outerHTML, innerHTML } = blot.domNode;
      const [start, end] = outerHTML.split(`>${innerHTML}<`);
      // TODO cleanup
      if (start === '<table') {
        return `<table style="border: 1px solid #000;">${parts.join('')}<${end}`;
      }
      return `${start}>${parts.join('')}<${end}`;
    }
    return blot.domNode.outerHTML;
  }
  
  function combineFormats(formats, combined) {
    return Object.keys(combined).reduce((merged, name) => {
      if (formats[name] == null) return merged;
      if (combined[name] === formats[name]) {
        merged[name] = combined[name];
      } else if (Array.isArray(combined[name])) {
        if (combined[name].indexOf(formats[name]) < 0) {
          merged[name] = combined[name].concat([formats[name]]);
        }
      } else {
        merged[name] = [combined[name], formats[name]];
      }
      return merged;
    }, {});
  }
  
  function getListType(type) {
    const tag = type === 'ordered' ? 'ol' : 'ul';
    switch (type) {
      case 'checked':
        return [tag, ' data-list="checked"'];
      case 'unchecked':
        return [tag, ' data-list="unchecked"'];
      default:
        return [tag, ''];
    }
  }
  
  function normalizeDelta(delta) {
    return delta.reduce((normalizedDelta, op) => {
      if (typeof op.insert === 'string') {
        const text = op.insert.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
        return normalizedDelta.insert(text, op.attributes);
      }
      return normalizedDelta.push(op);
    }, new Delta());
  }
  
  function shiftRange({ index, length }, amount) {
    return new Range(index + amount, length);
  }
  
  export default Editor;
  