Mark class

import DOMIterator from './domiterator';
import RegExpCreator from './regexpcreator';

/**
 * Marks search terms in DOM elements
 * @example
 * new Mark(document.querySelector('.context')).mark('lorem ipsum');
 * @example
 * new Mark(document.querySelector('.context')).markRegExp(/lorem/gmi);
 * @example
 * new Mark('.context').markRanges([{start:10,length:0}]);
 */
class Mark {

  /**
   * @param {HTMLElement|HTMLElement[]|NodeList|string} ctx - The context DOM
   * element, an array of DOM elements, a NodeList or a selector
   */
  constructor(ctx) {
    /**
     * The context of the instance. Either a DOM element, an array of DOM
     * elements, a NodeList or a selector
     * @type {HTMLElement|HTMLElement[]|NodeList|string}
     * @access protected
     */
    this.ctx = ctx;
    /**
     * The array of node names which must be excluded from search
     * @type {array}
     * @access protected
     */
    this.nodeNames = ['script', 'style', 'title', 'head', 'html'];
  }

  /**
   * @typedef Mark~commonOptions
   * @type {object.<string>}
   * @property {object} [window] - A window object
   * @property {Highlight} [highlight] - A Highlight object
   * @property {string} [element="mark"] - HTML element tag name
   * @property {string} [className] - An optional class name
   * @property {string[]} [exclude] - An array with exclusion selectors.
   * Elements matching those selectors will be ignored
   * @property {boolean} [iframes=false] - Whether to search inside iframes
   * @property {number} [iframesTimeout=5000] - Maximum ms to wait for a load
   * event of an iframe
   * @property {boolean} [acrossElements=false] - Whether to find matches across HTML elements.
   * By default, only matches within single HTML elements will be found
   * @property {Mark~markEachCallback} [each]
   * @property {Mark~markNoMatchCallback} [noMatch]
   * @property {Mark~commonDoneCallback} [done]
   * @property {boolean} [debug=false] - Whether to log messages
   * @property {object} [log=window.console] - Where to log messages (only if debug is true)
   */

  /**
   * Options defined by the user. They will be initialized from one of the
   * public methods. See {@link Mark#mark}, {@link Mark#markRegExp},
   * {@link Mark#markRanges} and {@link Mark#unmark} for option properties.
   * @type {object}
   * @param {object} [val] - An object that will be merged with defaults
   * @access protected
   */
  set opt(val) {
    if ( !(val && val.window && val.window.document) && typeof window === 'undefined') {
      throw new Error('Mark.js: please provide a window object as an option.');
    }

    const win = val && val.window || window,
      // eslint-disable-next-line
      highlight = val && val.highlight && val.highlight instanceof Highlight;

    this._opt = Object.assign({}, {
      'window': win,
      'element': '',
      'className': '',
      'exclude': [],
      'iframes': false,
      'iframesTimeout': 5000,
      'separateWordSearch': true,
      'staticRanges': true,
      'rangeAcrossElements': true,
      'acrossElements': false,
      'ignoreGroups': 0,
      'each': () => {},
      'noMatch': () => {},
      'filter': () => true,
      'done': () => {},
      'debug': false,
      'log': win.console
    }, val);

    if ( !this._opt.element) {
      this._opt.element = 'mark';
    }
    // shortens a lengthy name
    this.filter = win.NodeFilter;
    // this empty text node used to simplify code
    this.empty = win.document.createTextNode('');

    if ( !this._opt.highlightName) {
      this._opt.highlightName = 'advanced-markjs';
    }

    if (highlight) {
      this.rangeArray = [];
    } else {
      this._opt.highlight = null;
    }
  }

  get opt() {
    return this._opt;
  }

  /**
   * An instance of DOMIterator
   * @type {DOMIterator}
   * @access protected
   */
  get iterator() {
    // always return new instance in case there were option changes
    return new DOMIterator(this.ctx, this.opt);
  }

  /**
   * Logs a message if log is enabled
   * @param {string} msg - The message to log
   * @param {string} [level="debug"] - The log level, e.g. <code>warn</code>
   * <code>error</code>, <code>debug</code>
   * @access protected
   */
  log(msg, level = 'debug') {
    if (this.opt.debug) {
      const log = this.opt.log;
      if (typeof log === 'object' && typeof log[level] === 'function') {
        log[level](`mark.js: ${msg}`);
      }
    }
  }

  /**
   * @typedef Mark~logObject
   * @type {object}
   * @property {string} message - The message
   * @property {object} obj - The object
   */

  /**
   * Logs errors and info
   * @param {array} array - The array of objects
   */
  report(array) {
    array.forEach(item => {
      this.log(`${item.text} ${JSON.stringify(item.obj)}`, item.level || 'debug');
      if ( !item.skip) {
        this.opt.noMatch(item.obj);
      }
    });
  }

  /**
   * Splits string into separate words if 'separateWordSearch' option has value 'true' but,
   * if it has string value 'preserveTerms', prevents splitting terms surrounding by double quotes.
   * Removes duplicate or empty entries and sort by the length in descending order.
   * It also initializes termStats object.
   * @param {string|string[]} sv - Search value, either a string or an array of strings
   * @return {object}
   * @access protected
   */
  getSeachTerms(sv) {
    const search = typeof sv === 'string' ? [sv] : sv,
      separate = this.opt.separateWordSearch,
      array = [],
      termStats = {},
      split = str => {
        str.split(/ +/).forEach(word => add(word));
      },
      add = str => {
        if (str.trim() && !array.includes(str)) {
          array.push(str);
          // initializes term property
          termStats[str] = 0;
        }
      };

    search.forEach(str => {
      if (separate) {
        if (separate === 'preserveTerms') {
          // allows highlight quoted terms no matter how many quotes it contains on each side,
          // e.g. ' ""term"" ' or ' """"term" '
          str.split(/"("*[^"]+"*)"/).forEach((term, i) => {
            if (i % 2 > 0) add(term);
            else split(term);
          });
        } else {
          split(str);
        }
      } else {
        add(str);
      }
    });
    array.sort((a, b) => b.length - a.length);
    return { terms: array, termStats };
  }

  /**
   * Check if a value is a number
   * @param {number|string} value - the value to check;
   * numeric strings allowed
   * @return {boolean}
   * @access protected
   */
  isNumeric(value) {
    // http://stackoverflow.com/a/16655847/145346
    // eslint-disable-next-line eqeqeq
    return Number(parseFloat(value)) == value;
  }

  /**
   * Filters valid ranges, sorts and, if wrapAllRanges option is false, filters out nesting/overlapping ranges
   * @param {Mark~setOfRanges} array - unprocessed raw array
   * @param {Mark~logObject} logs - The array of logs objects
   * @return {Mark~setOfRanges} - processed array with any invalid entries removed
   * @access protected
   */
  checkRanges(array, logs, min, max) {
    // a range object must have the start and length properties with numeric values
    // [{start: 0, length: 5}, ..]
    const level = 'error';

    // filters and sorts valid ranges
    const ranges = array.filter(range => {
      if (this.isNumeric(range.start) && this.isNumeric(range.length)) {
        range.start = parseInt(range.start);
        range.length = parseInt(range.length);

        if (range.start >= min && range.start < max && range.length > 0) {
          return true;
        }
      }
      logs.push({ text: 'Invalid range: ', obj: range, level });
      return false;
    }).sort((a, b) => a.start - b.start);

    if (this.opt.wrapAllRanges) {
      return ranges;
    }

    let lastIndex = 0, index;
    // filters out nesting/overlapping ranges
    return ranges.filter(range => {
      index = range.start + range.length;

      if (range.start >= lastIndex) {
        lastIndex = index;
        return true;
      }
      logs.push({ text: (index < lastIndex ? 'Nest' : 'Overlapp') + 'ing range: ', obj: range, level });
      return false;
    });
  }

  /**
   * @typedef Mark~blockElementsBoundaryObject
   * @type {object}
   * @property {array} [tagNames] - The array of custom tag names
   * @property {boolean} [extend] - Whether to extend the default boundary elements with custom elements
   * or set only custom elements to boundary type
   * @property {string} [char] - The custom separating char
   */
  /**
  * Sets type: 1 - separate by space, 2 - separate by boundary char with space(s)
  * @param {object} tags - The object containing HTML element tag names
  */
  setType(tags, boundary) {
    const custom = Array.isArray(boundary.tagNames) && boundary.tagNames.length;

    if (custom) {
      // normalizes custom elements names and adds to the tags object with boundary type value
      boundary.tagNames.forEach(name => tags[name.toLowerCase()] = 2);
    }
    // if not extend, the only custom tag names are set to a boundary type
    if ( !custom || boundary.extend) {
      // sets all tags value to the boundary
      for (const key in tags) {
        tags[key] = 2;
      }
    }
    tags['br'] = 3;
  }

  /**
   * @typedef Mark~nodeInfoAcross
   * @property {Text} node - The DOM text node
   * @property {number} start - The start index within the composite string
   * @property {number} end - The end index within the composite string
   * @property {number} offset - The offset is used to correct position if a space or string
   * was added to end of composite string after this node textContent
   */

  /**
   * @typedef Mark~getTextNodesAcrossDict
   * @type {object.<string>}
   * @property {string} text - The composite string of all text nodes
   * @property {Mark~nodeInfoAcross[]} nodes - An array of node info objects
   * @property {number} lastIndex - The property used to store the nodes last index
   */

  /**
   * Callback
   * @callback Mark~getTextNodesAcrossCallback
   * @param {Mark~getTextNodesAcrossDict}
   */
  /**
   * Calls the callback with an object containing all text nodes (including iframe text nodes)
   * with start and end positions and the composite value of them (string)
   * @param {Mark~getTextNodesAcrossCallback} cb - Callback
   * @access protected
   */
  getTextNodesAcross(cb) {
    // a space or string can be safely added to the end of a text node when two text nodes
    // are 'separated' by element with one of these names
    const tags = { div: 1, p: 1, li: 1, td: 1, tr: 1, th: 1, ul: 1,
      ol: 1, dd: 1, dl: 1, dt: 1, h1: 1, h2: 1, h3: 1, h4: 1,
      h5: 1, h6: 1, hr: 1, blockquote: 1, figcaption: 1, figure: 1,
      pre: 1, table: 1, thead: 1, tbody: 1, tfoot: 1, input: 1,
      img: 1, nav: 1, details: 1, label: 1, form: 1, select: 1, menu: 1,
      br: 3, menuitem: 1,
      main: 1, section: 1, article: 1, aside: 1, picture: 1, output: 1,
      button: 1, header: 1, footer: 1, address: 1, area: 1, canvas: 1,
      map: 1, fieldset: 1, textarea: 1, track: 1, video: 1, audio: 1,
      body: 1, iframe: 1, meter: 1, object: 1, svg: 1 };

    const nodes = [],
      boundary = this.opt.blockElementsBoundary,
      priorityType = boundary ? 2 : 1;

    let ch = '\x01', tempType, type, prevNode;

    if (boundary) {
      this.setType(tags, boundary);

      if (boundary.char) {
        ch = boundary.char.charAt(0);
      }
    }

    const obj = {
      text: '', regex: /\s/, tags: tags,
      boundary: boundary, str: '', ch: ch
    };

    this.iterator.forEachNode(this.filter.SHOW_ELEMENT | this.filter.SHOW_TEXT, node => { // each
      if (prevNode) {
        nodes.push(this.getNodeInfo(prevNode, node, type, obj));
      }
      type = null;
      prevNode = node;

    }, node => { // filter
      if (node.nodeType === 1) { // element
        tempType = tags[node.nodeName.toLowerCase()];

        if (tempType === 3) { // br element
          obj.str += '\n';
        }

        if ( !type || tempType === priorityType) {
          type = tempType;
        }
        return false;
      }
      return !this.excluded(node.parentNode);

    }, () => { // done
      // processes the last node
      if (prevNode) {
        nodes.push(this.getNodeInfo(prevNode, null, type, obj));
      }
      cb({
        text: obj.text,
        nodes: nodes,
        lastIndex: 0
      });
    });
  }

  /**
   * Creates object
   * @param {Text} prevNode - The previous DOM text node
   * @param {Text} node - The current DOM text node
   * @param {number|null} type - define how to separate the previous and current text nodes textContent;
   * type is null when nodes doesn't separated by block elements
   * @param {object} obj - The auxiliary object to pass multiple parameters to the method
   */
  getNodeInfo(prevNode, node, type, obj) {
    const start = obj.text.length,
      ch = obj.ch;
    let offset = 0,
      str = obj.str,
      text = prevNode.textContent;

    if (node) {
      const startBySpace = obj.regex.test(node.textContent[0]),
        both = startBySpace && obj.regex.test(text[text.length - 1]);

      if (obj.boundary || !both) {
        let separate = type;
        // searches for the first parent of the previous text node that met condition
        // and checks does they have the same parent or the parent contains the current text node
        if (!type) {
          let parent = prevNode.parentNode;
          while (parent) {
            type = obj.tags[parent.nodeName.toLowerCase()];
            if (type) {
              separate = !(parent === node.parentNode || parent.contains(node));
              break;
            }
            parent = parent.parentNode;
          }
        }

        if (separate) {
          if ( !both) {
            str += type === 1 ? ' ' : type === 2 ? ' ' + ch + ' ' : '';

          } else if (type === 2) {
            str += both ? ch : startBySpace ? ' ' + ch : ch + ' ';
          }
        }
      }
    }

    if (str) {
      text += str;
      offset = str.length;
      obj.str = '';
    }
    obj.text += text;

    return this.createInfo(prevNode, start, obj.text.length - offset, offset);
  }

  /**
   * @typedef Mark~nodeInfo
   * @property {Text} node - The DOM text node
   * @property {number} start - The start index within the composite string
   * @property {number} end - The end index within the composite string
   * @property {number} offset - This property is required for compatibility with [Mark~nodeInfoAcross]
   * for {@link Mark#markRanges}
   */

  /**
   * @typedef Mark~getTextNodesDict
   * @type {object.<string>}
   * @property {string} text - The composite value of all text nodes
   * @property {Mark~nodeInfo[]} nodes - The array of objects
   * @property {number} lastIndex - The property used to store the nodes last index
   */

  /**
   * Callback
   * @callback Mark~getTextNodesCallback
   * @param {Mark~getTextNodesDict}
   */
  /**
   * Calls the callback with an object containing all text nodes (including iframe text nodes)
   * with start and end positions and the composite value of them (string)
   * @param {Mark~getTextNodesCallback} cb - Callback
   * @access protected
   */
  getRangesTextNodes(cb, lines) {
    const nodes = [],
      regex = /\n/g,
      newLines = [0],
      show = this.filter.SHOW_TEXT | (lines ? this.filter.SHOW_ELEMENT : 0);
    let text = '',
      len = 0,
      rm;

    this.iterator.forEachNode(show, node => { // each
      if (lines) {
        while ((rm = regex.exec(node.textContent)) !== null) {
          newLines.push(len + rm.index);
        }
      }
      text += node.textContent;

      nodes.push({
        start: len,
        end: (len = text.length),
        offset: 0,
        node: node
      });

    }, node => { // filter
      if (lines && node.nodeType === 1) {
        if (node.tagName.toLowerCase() === 'br') {
          newLines.push(len);
        }
        return false;
      }
      return !this.excluded(node.parentNode);

    }, () => { // done
      const dict = { text, nodes, lastIndex: 0 };

      if (lines) {
        newLines.push(len);
        dict.newLines = newLines;
      }
      cb(dict);
    });
  }

  /**
   * @typedef Mark~nodeInfo
   * @property {Text} node - The DOM text node
   * @property {number} start - The start index within the composite string
   */

  /**
   * @typedef Mark~getTextNodesDict
   * @type {object.<string>}
   * @property {Mark~nodeInfo[]} nodes - The array of objects
   * @property {number} lastIndex - The property used to store the nodes last index
   */

  /**
   * Callback
   * @callback Mark~getTextNodesCallback
   * @param {Mark~getTextNodesDict}
   */
  /**
   * Calls the callback with an object containing all text nodes (including iframe text nodes)
   * @param {Mark~getTextNodesCallback} cb - Callback
   * @access protected
   */
  getTextNodes(cb) {
    const nodes = [];
    let start = 0;

    this.iterator.forEachNode(this.filter.SHOW_TEXT, node => { // each
      nodes.push({
        node,
        start
      });
      start += node.textContent.length;

    }, node => { // filter
      return !this.excluded(node.parentNode);

    }, () => { // done
      cb({ nodes, lastIndex: 0 });
    });
  }

  /**
   * Checks if an element matches any of the specified exclude selectors.
   * @param {HTMLElement} elem - The element to check
   * @return {boolean}
   * @access protected
   */
  excluded(elem) {
    // it's faster to check if an array contains the node name than a selector in 'DOMIterator.matches()'
    // also it allows using a string of selectors instead of an array with the 'exclude' option
    return this.nodeNames.includes(elem.nodeName.toLowerCase()) || DOMIterator.matches(elem, this.opt.exclude);
  }

  /**
   * Splits the text node into two or three nodes and wraps the necessary node or wraps the input node
   * Creates info object(s) related to the newly created node(s) and inserts into dict.nodes or replace an existing one
   * It doesn't create empty sibling text nodes when `Text.splitText()` method splits a text node at the start/end
   * @param {Mark~wrapRangeInsertDict} dict - The dictionary
   * @param {object} n - The currently processed info object
   * @param {number} s - The position where to start wrapping
   * @param {number} e - The position where to end wrapping
   * @param {number} start - The start position of the match
   * @param {number} index - The current index of the processed object
   * @return {object} Returns object containing the mark element, the splitted text node
   * that will appear after the wrapped text node, and increment number
   */
  wrapRangeInsert(dict, n, s, e, start, index) {
    const ended = e === n.node.textContent.length,
      end = n.end;
    // type: 0 - whole text node, 1 - from the start, 2 - to the end, 3 - between
    let type = 1,
      splitIndex = e,
      node = n.node;
    // prevents creating empty sibling text nodes at the start/end of a text node
    if (s !== 0) {
      node = node.splitText(s);
      splitIndex = e - s;
      type = ended ? 2 : 3;

    } else if (ended) { // whole
      type = 0;
    }

    const retNode = ended ? this.empty : node.splitText(splitIndex),
      mark = this.createElement(node),
      markChild = mark.childNodes[0],
      nodeInfo = this.createInfo(retNode, type === 0 || type === 2 ? end : n.start + e, end, n.offset);

    if (type === 0) {
      n.node = markChild;
      return { mark, nodeInfo, increment: 0 };
    }

    const info = this.createInfo(markChild, type === 1 ? n.start : start, n.start + e, 0);
    // inserts new node(s) info in dict.nodes depending where a range is located in a text node
    if (type === 1) {
      dict.nodes.splice(index, 1, info, nodeInfo);
    } else {
      if (type === 2) {
        dict.nodes.splice(index + 1, 0, info);
      } else {
        dict.nodes.splice(index + 1, 0, info, nodeInfo);
      }
      n.end = start;
      n.offset = 0;
    }
    return { mark, nodeInfo, increment: type < 3 ? 1 : 2 };
  }

  /**
   * Creates object
   * @param {Text} node - The DOM text node
   * @param {number} start - The position where to start wrapping
   * @param {number} end - The position where to end wrapping
   * @param {number} offset - The length of space/string that is added to end of composite string
   * after this node textContent
   */
  createInfo(node, start, end, offset) {
    return { node, start, end, offset };
  }

  /**
   * Each callback
   * @callback Mark~wrapRangeEachCallback
   * @param {HTMLElement|StaticRange|Range} node - The wrapped DOM element or range (Highlight API)
   */
  /**
   * Splits the text node into two or three nodes and wraps the necessary node or wraps the input node
   * It doesn't create empty sibling text nodes when `Text.splitText()` method splits a text node at the start/end
   * @param {Text} node - The DOM text node
   * @param {number} start - The position where to start wrapping
   * @param {number} end - The position where to end wrapping
   * @param {Mark~wrapRangeEachCallback} eachCb - Each callback
   * @return {Text}
   * @access protected
   */
  wrapRange(n, start, end, eachCb) {
    let node = n.node,
      retNode;

    if (this.rangeArray) {
      this.createRange(node, start, node, end, n.start + start, eachCb);
      retNode = node;

    } else {
      let ended = end === node.textContent.length,
        index = end;

      if (start !== 0) {
        node = node.splitText(start);
        index = end - start;
      }
      retNode = ended ? this.empty : node.splitText(index);
      eachCb(this.createElement(node));
    }
    return retNode;
  }

  /**
   * Each callback
   * @callback Mark~createRangeEachCallback
   * @param {StaticRange|Range} range - The created range
   * @param {boolean} true - Required only for across elements code with the rangeAcrossElements option
   */
  /**
   * Creates the new StaticRange/Range object with the specified parameters
   * @param {Text} startNode - The text node where a match is started
   * @param {number} startOffset - The start index of the match in startNode
   * @param {number} endNode - The text node where a match is ended
   * @param {number} endOffset - The end index of the match in endNode
   * @param {number} absoluteOffset - The absolute start index from the beginning of first context.
   * Uses to sort ranges by ascending order.
   * @param {Mark~createRangeEachCallback} eachCb - Each callback
   */
  createRange(startNode, startOffset, endNode, endOffset, absoluteOffset, eachCb) {
    let range;

    if (this.opt.staticRanges) {
      range = new StaticRange({ startContainer: startNode, startOffset, endContainer: endNode, endOffset });

    } else {
      range = new Range();
      range.setStart(startNode, startOffset);
      range.setEnd(endNode, endOffset);
    }

    range.absoluteOffset = absoluteOffset;

    eachCb(range, true);
    // a range can be destroyed on the 'each' callback
    if (range) this.rangeArray.push(range);
  }

  /**
   * Wraps the new element with the necessary attributes around text node
   * @param {Text} node - The DOM text node
   * @return {HTMLElement} Returns the created DOM node
   */
  createElement(node) {
    let markNode = this.opt.window.document.createElement(this.opt.element);
    markNode.setAttribute('data-markjs', 'true');

    if (this.opt.className) {
      markNode.setAttribute('class', this.opt.className);
    }
    markNode.textContent = node.textContent;
    node.parentNode.replaceChild(markNode, node);

    return markNode;
  }

  /**
   * @typedef Mark~wrapRangeAcrossDict
   * @type {object.<string>}
   * @property {string} text - The composite string of all text nodes
   * @property {Mark~nodeInfoAcross[]} nodes - An array of node info objects
   * @property {number} lastIndex - The property used to store the nodes last index
   */
  /**
   * Each callback
   * @callback Mark~wrapRangeAcrossEachCallback
   * @param {HTMLElement|StaticRange|Range} node - The wrapped DOM element or range (Highlight API)
   * @param {boolean} rangeStart - Indicate the start of the current range or always true (Highlight API)
   */

  /**
   * Filter callback
   * @callback Mark~wrapRangeAcrossFilterCallback
   * @param {Text|Text[]} node - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   */
  /**
   * Determines matches by start and end positions using the text node dictionary
   * and calls {@link Mark#wrapRange} or {@link Mark#wrapRangeInsert} to wrap them
   * @param {Mark~wrapRangeAcrossDict} dict - The dictionary
   * @param {number} start - The start index of the match
   * @param {number} end - The end index of the match
   * @param {Mark~wrapRangeAcrossFilterCallback} filterCb - Filter callback
   * @param {Mark~wrapRangeAcrossEachCallback} eachCb - Each callback
   * @access protected
   */
  wrapRangeAcross(dict, start, end, filterCb, eachCb) {
    // dict.lastIndex stores the last node index to avoid iteration from the beginning
    let i = dict.lastIndex,
      rangeStart = true,
      startInfo,
      filterNodes = [],
      e;
    const wrapAllRanges = this.opt.wrapAllRanges,
      highlightAPI = !!this.opt.highlight, // when using the Highlight API, no text nodes are split
      singleRange = highlightAPI && this.opt.rangeAcrossElements;

    if (wrapAllRanges) {
      // finds the start index in case of nesting/overlapping
      while (i > 0 && dict.nodes[i].start > start) {
        i--;
      }
    }

    for (i; i < dict.nodes.length; i++) {
      if (i + 1 === dict.nodes.length || dict.nodes[i+1].start > start) {
        let n = dict.nodes[i];

        if (singleRange) {
          filterNodes.push(n.node);

        } else if ( !filterCb(n.node)) {
          break;
        }
        // map range from dict.text to text node
        const s = start - n.start;
        e = (end > n.end ? n.end : end) - n.start;

        // prevents creating an empty mark node, prevents exception if something went wrong, useful for debug
        if (s >= 0 && e > s) {
          if (singleRange) {
            if (rangeStart) {
              startInfo = [n.node, s, n.start + s];
            }

          } else if ( !highlightAPI && wrapAllRanges) {
            const obj = this.wrapRangeInsert(dict, n, s, e, start, i);
            n = obj.nodeInfo;
            eachCb(obj.mark, rangeStart);

          } else {
            // when using the Highlight API and the 'rangeAcrossElements: false',
            // it creates multiple ranges for matches that are located across elements
            n.node = this.wrapRange(n, s, e, elemOrRange => {
              eachCb(elemOrRange, rangeStart);
            });
            // sets the new text node start index in the case of subsequent matches in the same text node
            if ( !highlightAPI) n.start += e;
          }
          rangeStart = false;
        }

        if (end > n.end) {
          // the range extends to the next text node
          start = n.end + n.offset;

        } else {
          // creates a single StaticRange/Range for matches that are located across elements
          if (startInfo && filterCb(filterNodes)) {
            this.createRange(startInfo[0], startInfo[1], n.node, e, startInfo[2], eachCb);
          }
          break;
        }
      }
    }
    // sets the last index
    dict.lastIndex = i;
  }

  /**
   * Filter callback before each wrapping
   * @callback Mark~wrapGroupsFilterCallback
   * @param {Text} node - The text node where the match occurs
   * @param {string} group - The current group matching string
   * @param {number} i - The current group index
   */
  /**
   * Callback for each wrapped element
   * @callback Mark~wrapGroupsEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {number} i - The current group index
   */

  /**
   * Wraps match groups with RegExp.hasIndices
   * @param {Text} node - The text node where the match occurs
   * @param {array} match - The result of RegExp exec() method
   * @param {RegExp} regex - The regular expression
   * @param {Mark~wrapGroupsFilterCallback} filterCb - Filter callback
   * @param {Mark~wrapGroupsEachCallback} eachCb - Each callback
   */
  wrapGroups(n, match, regex, filterCb, eachCb) {
    let lastIndex = 0,
      offset = 0,
      i = 0,
      highlightAPI = this.opt.highlight,
      isWrapped = false,
      group, start, end = 0;

    while (++i < match.length) {
      group = match[i];

      if (group) {
        start = match.indices[i][0];
        //it prevents marking nested group - parent group is already marked
        if (start >= lastIndex) {
          end = match.indices[i][1];

          if (filterCb(n.node, group, i)) {
            // when a group is wrapping, a text node is split at the end index,
            // so to correct the start & end indexes of a new text node, subtract
            // the end index of the last wrapped group (offset)
            n.node = this.wrapRange(n, start - offset, end - offset, elemOrRange => { // each
              eachCb(elemOrRange);
            });

            if (end > lastIndex) {
              lastIndex = end;
            }
            // when using the Highlight API, no text nodes are split
            if ( !highlightAPI) offset = end;
            isWrapped = true;
          }
        }
      }
    }
    // resets the lastIndex when at least one group is wrapped (prevents infinite loop)
    if (isWrapped) {
      if ( !highlightAPI) regex.lastIndex = 0;

    } else if (match[0].length === 0) {
      this.setLastIndex(regex, end);
    }
  }

  /**
   * Filter callback before each wrapping
   * @callback Mark~wrapGroupsAcrossFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {string} group - The current group matching string
   * @param {number} i - The current group index
   */
  /**
   * Callback for each wrapped element
   * @callback Mark~wrapGroupsAcrossEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {number} i - The current group index
   * @param {boolean} groupStart - Indicate the start of a group
   */

  /**
   * Wraps match groups with RegExp.hasIndices across elements
   * @param {Mark~wrapGroupsAcrossDict} dict - The dictionary
   * @param {array} match - The result of RegExp exec() method
   * @param {RegExp} regex - The regular expression
   * @param {Mark~wrapGroupsAcrossFilterCallback} filterCb - Filter callback
   * @param {Mark~wrapGroupsAcrossEachCallback} eachCb - Each callback
   */
  wrapGroupsAcross(dict, match, regex, filterCb, eachCb) {
    let lastIndex = 0,
      i = 0,
      end = 0,
      start,
      group,
      isWrapped;

    while (++i < match.length) {
      group = match[i];

      if (group) {
        start = match.indices[i][0];
        // the wrapAllRanges option allows wrapping nested group(s),
        // the 'start >= lastIndex' prevents wrapping nested group(s) - the parent group is already wrapped
        if (this.opt.wrapAllRanges || start >= lastIndex) {
          end = match.indices[i][1];
          isWrapped = false;

          this.wrapRangeAcross(dict, start, end, nodeOrArray => { // filter
            return filterCb(nodeOrArray, group, i);

          }, (elemOrRange, groupStart) => { // each
            isWrapped = true;
            eachCb(elemOrRange, groupStart);
          });
          // group may be filtered out
          if (isWrapped && end > lastIndex) {
            lastIndex = end;
          }
        }
      }
    }

    if (match[0].length === 0) {
      this.setLastIndex(regex, end);
    }
  }

  /**
   * When the length of a match is zero, there is a need to set the RegExp lastIndex depending on conditions.
   * It's necessary to avoid infinite loop and set position from which to start the next match
   * @param {RegExp} regex - The regular expression
   * @param {number} end - The end index of the last processed group
   */
  setLastIndex(regex, end) {
    const index = regex.lastIndex;
    // end > index - case when a capturing group is inside positive lookahead assertion
    // end > 0 - case when a match is filtered out or a capturing group is inside positive lookbehind assertion
    regex.lastIndex = end > index ? end : end > 0 ? index + 1 : Infinity;
  }

  /**
   * @typedef Mark~filterInfoObject
   * @type {object}
   * @property {array} match - The result of RegExp exec() method
   * @property {boolean} matchStart - Indicate the start of match. It's only available
   * with the 'acrossElements' option
   * @property {number} groupIndex - The group index. It's only available
   * with 'separateGroups' option
   * @property {object} execution - The helper object for early abort. Contains
   * boolean 'abort' property.
   */
  /**
   * @typedef Mark~eachInfoObject
   * @type {object}
   * @property {array} match - The result of RegExp exec() method
   * @property {boolean} matchStart - Indicate the start of match. It's only available
   * with the 'acrossElements' option
   * @property {number} count - The current number of matches
   * @property {number} groupIndex - The index of current match group. It's only
   * available with 'separateGroups' option
   * @property {boolean} groupStart - Indicate the start of group. It's only
   * available with both 'acrossElements' and 'separateGroups' options
   */
  /**
   * @typedef Mark~infoObject
   * @type {object}
   * @property {number} count - The number of matches so far
   * @property {object} execution - The helper object for early abort. Contains boolean 'abort' property.
   */

  /**
   * Group filter callback before each wrapping
   * @callback Mark~processGroupsFilterCallback
   * @param {Text} node - The text node where the match occurs
   * @param {string} group - The matching string of the current group
   * @param {Mark~filterInfoObject} info - The object containing the match information
   */
  /**
   * Callback for each wrapped element
   * @callback Mark~processGroupsEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~eachInfoObject} - The object containing the match information
   */
  /**
   * Callback on end
   * @callback Mark~processGroupsEndCallback
   * @param {number} count - The number of matches
   */

  /**
   * Wraps match capturing groups
   * @param {RegExp} regex - The regular expression to be searched for
   * @param {number} unused
   * @param {Mark~infoObject} info - The object used on filter and each callbacks
   * @param {Mark~processGroupsFilterCallback} filterCb - Filter callback
   * @param {Mark~processGroupsEachCallback} eachCb - Each callback
   * @param {Mark~processGroupsEndCallback} endCb
   * @access protected
   */
  processGroups(regex, unused, info, filterCb, eachCb, endCb) {
    let count = info.count, match, filterStart, eachStart;

    this.getTextNodes(dict => {
      dict.nodes.every(n => {
        while ((match = regex.exec(n.node.textContent)) !== null) {
          info.match = match;
          filterStart = eachStart = true;

          this.wrapGroups(n, match, regex, (node, group, grIndex) => { // filter
            info.matchStart = filterStart;
            info.groupIndex = grIndex;
            filterStart = false;
            return filterCb(node, group, info);

          }, (elemOrRange) => { // each
            if (eachStart) count++;

            info.count = count;
            info.matchStart = eachStart;
            eachStart = false;

            eachCb(elemOrRange, info);
          });

          if (info.abort) break;
        }
        // breaks loop on custom abort
        return !info.abort;
      });
      endCb(count);
    });
  }

  /**
   * Filter callback before each wrapping
   * @callback Mark~processGroupsAcrossFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {string} group - The matching string of the current group
   * @param {Mark~filterInfoObject} info - The object containing the match information
   */
  /**
   * Callback for each wrapped element
   * @callback Mark~processGroupsAcrossEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~eachInfoObject} - The object containing the match information
   */
  /**
   * Callback on end
   * @callback Mark~processGroupsAcrossEndCallback
   * @param {number} count - The number of all matches
   */
  /**
   * Wraps match capturing groups across elements
   * @param {RegExp} regex - The regular expression to be searched for
   * @param {number} unused
   * @param {Mark~infoObject} info - The object used on filter and each callbacks
   * @param {Mark~processGroupsAcrossFilterCallback} filterCb - Filter callback
   * @param {Mark~processGroupsAcrossEachCallback} eachCb - Each callback
   * @param {Mark~processGroupsAcrossEndCallback} endCb
   * @access protected
   */
  processGroupsAcross(regex, unused, info, filterCb, eachCb, endCb) {
    let count = info.count, match, filterStart, eachStart;

    this.getTextNodesAcross(dict => {
      while ((match = regex.exec(dict.text)) !== null) {
        info.match = match;
        filterStart = eachStart = true;

        this.wrapGroupsAcross(dict, match, regex, (nodeOrArray, group, grIndex) => { // filter
          // eslint-disable-next-line
          info.groupStart = undefined;
          info.matchStart = filterStart;
          info.groupIndex = grIndex;
          filterStart = false;
          return filterCb(nodeOrArray, group, info);

        }, (elemOrRange, groupStart) => { // each
          if (eachStart) count++;

          info.count = count;
          info.matchStart = eachStart;
          info.groupStart = groupStart;
          eachCb(elemOrRange, info);
          eachStart = false;
        });
        // breaks loop on custom abort
        if (info.abort) break;
      }
      endCb(count);
    });
  }

  /**
   * Filter callback before each wrapping
   * @callback Mark~processMatchesFilterCallback
   * @param {Text} node - The text node where the match occurs
   * @param {string} str - The matching string
   * @param {Mark~filterInfoObject} filterInfo - The object containing the match information
   */
  /**
   * Callback for each wrapped element
   * @callback Mark~processMatchesEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~eachInfoObject} eachInfo - The object containing the match information
   */
  /**
   * Callback on end
   * @callback Mark~processMatchesEndCallback
   * @param {number} count - The number of all matches
   */

  /**
   * Wraps the instance element and class around matches within single HTML elements in all contexts
   * @param {RegExp} regex - The regular expression to be searched for
   * @param {number} ignoreGroups - A number of RegExp capturing groups to ignore from the beginning of a match
   * @param {Mark~infoObject} info - The object used on filter and each callbacks
   * @param {Mark~processMatchesFilterCallback} filterCb - Filter callback
   * @param {Mark~processMatchesEachCallback} eachCb - Each callback
   * @param {Mark~processMatchesEndCallback} endCb
   * @access protected
   */
  processMatches(regex, ignoreGroups, info, filterCb, eachCb, endCb) {
    const index = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
    let count = info.count, match, str;

    this.getTextNodes(dict => {
      dict.nodes.every(n => {
        while ((match = regex.exec(n.node.textContent)) !== null) {
          // prevents an infinite loop
          if ((str = match[index]) === '') {
            regex.lastIndex++;
            continue;
          }
          info.match = match;

          if ( !filterCb(n.node, str, info)) {
            continue;
          }
          // calculates the start index inside node.textContent
          let i = 0, start = match.index;
          while (++i < index) {
            if (match[i]) { // allows any ignore group to be undefined
              start += match[i].length;
            }
          }

          n.node = this.wrapRange(n, start, start + str.length, elemOrRange => {
            info.count = ++count;
            eachCb(elemOrRange, info);
          });
          // when using the Highlight API, no text nodes are split
          if ( !this.opt.highlight) regex.lastIndex = 0;

          if (info.abort) break;
        }
        // breaks loop on custom abort
        return !info.abort;
      });
      endCb(count);
    });
  }

  /**
   * Filter callback before each wrapping
   * @callback Mark~processMatchesAcrossFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {string} str - The matching string
   * @param {Mark~filterInfoObject} filterInfo - The object containing the match information
   */
  /**
   * Callback for each wrapped element
   * @callback Mark~processMatchesAcrossEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~eachInfoObject} - The object containing the match information
   */

  /**
   * Callback on end
   * @callback Mark~processMatchesAcrossEndCallback
   * @param {number} count - The number of all matches
   */
  /**
   * Wraps the instance element and class around matches across all HTML elements in all contexts
   * @param {RegExp} regex - The regular expression to be searched for
   * @param {number} ignoreGroups - A number of RegExp capturing groups to ignore from the beginning of a match
   * @param {Mark~infoObject} info - The object used on filter and each callbacks
   * @param {Mark~processMatchesAcrossFilterCallback} filterCb - Filter callback
   * @param {Mark~processMatchesAcrossEachCallback} eachCb - Each callback
   * @param {Mark~processMatchesAcrossEndCallback} endCb
   * @access protected
   */
  processMatchesAcross(regex, ignoreGroups, info, filterCb, eachCb, endCb) {
    const index = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
    let count = info.count, match, str, matchStart;

    this.getTextNodesAcross(dict => {
      while ((match = regex.exec(dict.text)) !== null) {
        // prevents an infinite loop
        if ((str = match[index]) === '') {
          regex.lastIndex++;
          continue;
        }
        info.match = match;
        matchStart = true;

        // calculates the start index inside dict.text
        let i = 0, start = match.index;
        while (++i < index) {
          if (match[i]) { // allows any ignore group to be undefined
            start += match[i].length;
          }
        }

        this.wrapRangeAcross(dict, start, start + str.length, nodeOrArray => { // filter
          info.matchStart = matchStart;
          matchStart = false;
          return filterCb(nodeOrArray, str, info);

        }, (elemOrRange, mStart) => { // each
          if (mStart) count++;

          info.count = count;
          info.matchStart = mStart;
          eachCb(elemOrRange, info);
        });

        if (info.abort) break;
      }
      endCb(count);
    });
  }

  /**
   * Callback for each wrapped element
   * @callback Mark~wrapRangesEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~rangeObject} range - the current range object; the start and length values can be
   * modified if they are not numeric integers
   * @param {Mark~rangeInfoObject} rangeInfo - The object containing the range information
   */
  /**
   * Filter callback before each wrapping
   * @callback Mark~wrapRangesFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {Mark~rangeObject} range - the current range object
   * @param {string} substr - string extracted from the matching range
   * @param {number} index - The current range index ???
   */

  /**
   * Callback on end
   * @callback Mark~wrapRangesEndCallback
   * @param {number} count - The number of wrapped ranges
   * @param {Mark~logObject[]} logs - The array of objects
   */
  /**
   * Wraps the indicated ranges across all HTML elements in all contexts
   * @param {Mark~setOfRanges} ranges
   * @param {Mark~wrapRangesFilterCallback} filterCb
   * @param {Mark~wrapRangesEachCallback} eachCb
   * @param {Mark~wrapRangesEndCallback} endCb
   * @access protected
   */
  processRanges(ranges, filterCb, eachCb, endCb) {
    const lines = this.opt.markLines,
      logs = [],
      skipped = [],
      level = 'warn';
    let count = 0;

    this.getRangesTextNodes(dict => {
      const max = lines ? dict.newLines.length : dict.text.length,
        array = this.checkRanges(ranges, logs, lines ? 1 : 0, max);

      array.forEach((range, index) => {
        let start = range.start,
          end = start + range.length;

        if (end > max) {
          // with wrapAllRanges option, there can be several report of limited ranges
          logs.push({ text: `Range was limited to: ${max}`, obj: range, skip: true, level });
          end = max;
        }

        if (lines) {
          start = dict.newLines[start-1];
          if (dict.text[start] === '\n') {
            start++;
          }
          end = dict.newLines[end-1];
        }

        const substr = dict.text.slice(start, end);

        if (substr.trim()) {
          this.wrapRangeAcross(dict, start, end, nodeOrArray => {    // filter
            return filterCb(nodeOrArray, range, substr, index);

          }, (elemOrRange, rangeStart) => {    // each
            if (rangeStart) {
              count++;
            }
            eachCb(elemOrRange, range, {
              matchStart: rangeStart,
              count: count
            });
          });
        } else {
          // whitespace only; even if wrapped it is not visible
          logs.push({ text: 'Skipping whitespace only range: ', obj: range, level });
          skipped.push(range);
        }
      });

      this.log(`Valid ranges: ${JSON.stringify(array.filter(range => !skipped.includes(range)))}`);
      endCb(count, logs);
    }, lines);
  }

  /**
   * Unwraps the specified DOM node with its content (text nodes or HTML)
   * without destroying possibly present events (using innerHTML) and normalizes text nodes
   * @param {HTMLElement} node - The DOM node to unwrap
   * @access protected
   */
  unwrapMatches(node) {
    const parent = node.parentNode,
      first = node.firstChild;

    if (node.childNodes.length === 1) {
      // unwraps and normalizes text nodes
      if (first.nodeType === 3) {
        // the most common case - mark element with child text node
        const previous = node.previousSibling,
          next = node.nextSibling;

        if (previous && previous.nodeType === 3) {
          if (next && next.nodeType === 3) {
            previous.nodeValue += first.nodeValue + next.nodeValue;
            parent.removeChild(next);

          } else {
            previous.nodeValue += first.nodeValue;
          }

        } else if (next && next.nodeType === 3) {
          next.nodeValue = first.nodeValue + next.nodeValue;

        } else {
          parent.replaceChild(node.firstChild, node);
          return;
        }
        parent.removeChild(node);

      } else {
        // most likely is a nested mark element or modified by user element
        parent.replaceChild(node.firstChild, node);
      }

    } else {
      if ( !first) {
        // an empty mark element
        parent.removeChild(node);

      } else {
        // most likely is a nested mark element(s) with sibling text node(s) or modified by user element(s)
        let docFrag = this.opt.window.document.createDocumentFragment();
        while (node.firstChild) {
          docFrag.appendChild(node.removeChild(node.firstChild));
        }
        parent.replaceChild(docFrag, node);
      }
      parent.normalize();
    }
  }

  /**
   * Callback to filter matches
   * @callback Mark~markRegExpFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {string} match - The matching string:
   * 1) without 'ignoreGroups' and 'separateGroups' options - the whole match.
   * 2) with 'ignoreGroups' option - the match[ignoreGroups+1] group matching string.
   * 3) with 'separateGroups' option - the current group matching string
   * @param {number} matchesSoFar - The number of wrapped matches so far
   * @param {Mark~filterInfoObject} filterInfo - The object containing the match information.
   */
  /**
   * Callback for each marked element
   * @callback Mark~markRegExpEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~eachInfoObject} eachInfo - The object containing the match information.
   */
  /**
   * Callback if there were no matches
   * @callback Mark~markRegExpNoMatchCallback
   * @param {RegExp} regexp - The regular expression
   */

  /**
   * These options also include the common options from {@link Mark~commonOptions}
   * @typedef Mark~markRegExpOptions
   * @type {object.<string>}
   * @property {number} [ignoreGroups=0] - A number of RegExp capturing groups to ignore from the beginning of a match
   * @property {boolean} [separateGroups] - Whether to mark RegExp capturing groups instead of whole match
   * @property {Mark~markRegExpNoMatchCallback} [noMatch]
   * @property {Mark~markRegExpFilterCallback} [filter]
   * @property {Mark~markRegExpEachCallback} [each]
   */
  /**
   * Marks a custom regular expression
   * @param {RegExp} regexp - The regular expression
   * @param {Mark~markRegExpOptions} [opt] - Optional options object
   * @access public
   */
  markRegExp(regexp, opt) {
    this.opt = opt;

    let totalMarks = 0,
      matchesSoFar = 0,
      across = this.opt.acrossElements,
      fn = 'processMatches';

    if (this.opt.separateGroups) {
      if ( !regexp.hasIndices) {
        throw new Error('Mark.js: RegExp must have a `d` flag');
      }
      fn = across ? 'processGroupsAcross' : 'processGroups';

    } else if (across) {
      fn = 'processMatchesAcross';
    }

    const info = { count: 0, abort: false };

    // solves backward-compatibility
    if ( !regexp.global && !regexp.sticky) {
      let splits = regexp.toString().split('/');
      regexp = new RegExp(regexp.source, 'g' + splits[splits.length-1]);
      this.log('RegExp is recompiled - it must have a `g` flag', 'warn');
    }
    this.log(`RegExp "${regexp}"`);

    this[fn](regexp, this.opt.ignoreGroups, info, (nodeOrArray, match, filterInfo) => { // filter
      return this.opt.filter(nodeOrArray, match, matchesSoFar, filterInfo);

    }, (elemOrRange, eachInfo) => { // each
      matchesSoFar = eachInfo.count;
      totalMarks++;
      this.opt.each(elemOrRange, eachInfo);

    }, (totalMatches) => { // done
      if (totalMatches === 0) {
        this.opt.noMatch(regexp);
      }
      this.registerHighlight();
      this.opt.done(totalMarks, totalMatches);
    });
  }

  /**
   * Callback to filter matches
   * @callback Mark~markFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {string} term - The current term
   * @param {number} matches - The number of all wrapped matches so far
   * @param {number} termMatches - The number of wrapped matches for the current term so far
   * @param {Mark~filterInfoObject} filterInfo - The object containing the match information.
   */
  /**
   * Callback for each marked element
   * @callback Mark~markEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~eachInfoObject} eachInfo - The object containing the match information.
   */
  /**
   * Callback if there were no matches
   * @callback Mark~markNoMatchCallback
   * @param {string[]} array - Not found search terms
   */
  /**
   * Callback when finished
   * @callback Mark~commonDoneCallback
   * @param {number} totalMatches - The total number of matches
   * @param {object} termStats - The object containing an individual term's matches counts for {@link Mark#mark} method
   */

  /**
   * These options also include the common options from {@link Mark~commonOptions}
   * @typedef Mark~markOptions
   * @type {object.<string>}
   * @property {boolean} [separateWordSearch=true] - Whether to break term into words
   * and search for individual word instead of the complete term
   * @property {Mark~markFilterCallback} [filter]
   */
  /**
    * Marks the specified search terms
    * @param {string|string[]} [sv] - A search string or an array of search strings
    * @param  {Mark~markOptions} [opt] - Optional options object
    * @access public
    */
  mark(sv, opt) {
    this.opt = opt;
    const { terms, termStats } = this.getSeachTerms(sv);

    if ( !terms.length) {
      this.opt.done(0, 0, termStats);
      return;
    }

    let index = 0,
      totalMarks = 0,
      matchesSoFar = 0,
      term;

    const across = this.opt.acrossElements,
      fn = across ? 'processMatchesAcross' : 'processMatches',
      array = this.getRegExps(terms),
      info = { count: 0, abort: false };

    const loop = ({ regex, regTerms }) => {
      this.log(`RegExp ${regex}`);

      this[fn](regex, 1, info, (nodeOrArray, _, filterInfo) => { // filter
        if ( !across || filterInfo.matchStart) {
          term = this.getCurrentTerm(filterInfo.match, regTerms);
        }
        // termStats[term] is the number of wrapped matches so far for the current term
        return this.opt.filter(nodeOrArray, term, matchesSoFar, termStats[term], filterInfo);

      }, (elemOrRange, eachInfo) => { // each
        totalMarks++;
        matchesSoFar = eachInfo.count;

        if ( !across || eachInfo.matchStart) {
          termStats[term] += 1;
        }
        this.opt.each(elemOrRange, eachInfo);

      }, (totalMatches) => { // end
        const noMatches = regTerms.filter(term => termStats[term] === 0);
        if (noMatches.length) {
          this.opt.noMatch(noMatches);
        }

        if ( !info.abort && ++index < array.length) {
          loop(array[index]);
        } else {
          this.registerHighlight();
          this.opt.done(totalMarks, totalMatches, termStats);
        }
      });
    };

    loop(array[0]);
  }

  /**
    * @param {array} match - The result of RegExp exec() method
    * @param {array} terms - The array of strings
    * @return {string} - The matched term
    */
  getCurrentTerm(match, terms) {
    // it's better to search from the end of array because the terms are sorted by
    // length in descending order - shorter term appears more frequently
    let i = match.length;
    while (--i > 2) {
      // the current term index is the first non-undefined capturing group index minus 3
      if (match[i]) {
        // the first 3 groups are: match[0], lookbehind, and main group
        return terms[i-3];
      }
    }
    return ' ';
  }

  /**
    * Splits an array of strings into chunks by the specified number and creates RegExp from each chunk
    * @param {array} terms - The array of strings
    * @return {array} - The array of arrays with RegExp and its term chunks
    */
  getRegExps(terms) {
    const creator = new RegExpCreator(this.opt),
      option = this.opt.combineBy || this.opt.combinePatterns,
      length = terms.length,
      array = [];
    let num = 100,
      value;

    if (option === Infinity) {
      num = length;
    } else if ( !isNaN(+option) && (value = parseInt(option)) > 0) {
      num = value;
    }

    for (let i = 0; i < length; i += num) {
      // get a chunk of terms to create combine pattern
      const chunk = terms.slice(i, Math.min(i + num, length));
      array.push({ regex: creator.create(chunk), regTerms: chunk });
    }
    return array;
  }

  /**
   * @typedef Mark~rangeObject
   * @type {object}
   * @property {number} start - The start index within the composite string
   * @property {number} length - The length of the string to mark within the composite string.
   */
  /**
   * @typedef Mark~setOfRanges
   * @type {object[]}
   * @property {Mark~rangeObject}
   */

  /**
   * @typedef Mark~rangeInfoObject
   * @type {object}
   * @property {boolean} matchStart - Indicate the start of range
   * @property {number} count - The current number of wrapped ranges
   */
  /**
   * These options also include the common options from {@link Mark~commonOptions}
   * @typedef Mark~markRangesOptions
   * @type {object.<string>}
   * @property {Mark~markRangesEachCallback} [each]
   * @property {Mark~markRangesNoMatchCallback} [noMatch]
   * @property {Mark~markRangesFilterCallback} [filter]
   */

  /**
   * Callback to filter matches
   * @callback Mark~markRangesFilterCallback
   * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
   * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
   * @param {Mark~rangeObject} range - The range object
   * @param {string} match - The current range matching string
   * @param {number} index - The current range index ???
   */
  /**
   * Callback for each marked element
   * @callback Mark~markRangesEachCallback
   * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
   * @param {Mark~rangeObject} range - The range object
   * @param {Mark~rangeInfoObject}  - The object containing the range information
   */
  /**
   * Callback if a processed range is invalid, out-of-bounds, overlaps another
   * range, or only matches whitespace
   * @callback Mark~markRangesNoMatchCallback
   * @param {Mark~rangeObject} range - The range object
   */

  /**
   * Marks an array of objects containing start and length properties
   * @param {Mark~setOfRanges} ranges - The original array of objects
   * @param {Mark~markRangesOptions} [opt] - Optional options object
   * @access public
   */
  markRanges(ranges, opt) {
    this.opt = opt;

    if (Array.isArray(ranges)) {
      let totalMarks = 0;

      this.processRanges(ranges, (nodeOrArray, range, match, index) => { // filter
        return this.opt.filter(nodeOrArray, range, match, index);

      }, (elemOrRange, range, rangeInfo) => { // each
        totalMarks++;
        this.opt.each(elemOrRange, range, rangeInfo);

      }, (totalRanges, logs) => { // end
        this.report(logs);
        this.registerHighlight();
        this.opt.done(totalMarks, totalRanges);
      });

    } else {
      this.report([{ text: 'markRanges() accept an array of objects: ', obj: ranges, level: 'error' }]);
      this.opt.done(0, 0);
    }
  }

  /**
   * Iterates over specified names or the default name, if the HighlightRegistry contains name,
   * it deletes all (if the 'exclude' option is specified according this option) ranges from the Highlight object;
   * next, if allowed, unwraps all marked elements inside the context and normalizes text nodes
   * @param {Mark~commonOptions} [opt] - Optional options object without each,
   * noMatch and acrossElements properties
   * @access public
   */
  unmark(opt) {
    this.opt = opt;
    const registry = CSS.highlights,
      exclude = this.opt.exclude && this.opt.exclude.length;
    // if the browser supports the Highlight API
    if (registry) {
      let names = this.opt.highlightName,
        highlight;
      if (typeof names === 'string') names = [names];

      names.forEach((name) => {
        if ((highlight = registry.get(name)) && highlight.size) {
          // unregister the Highlight object before deleting ranges
          registry.delete(name);
          // iterates over highlight when 'exclude' option is specified
          if (exclude) {
            highlight.forEach((range) => {
              let node = range.startContainer;

              if (node.nodeType === 3) node = node.parentNode;

              if ( !this.excluded(node)) highlight.delete(range);
            });

          } else {
            // much faster way to remove highlights
            highlight.clear();
          }
          // register the Highlight object with excluded ranges
          if (highlight.size) registry.set(name, highlight);
        }
      });
    }
    // removes only StaticRange/Range objects
    if (this.opt.highlight) {
      this.opt.done();
      return;
    }

    let selector = this.opt.element + '[data-markjs]';

    if (this.opt.className) {
      selector += `.${this.opt.className}`;
    }
    this.log(`Removal selector "${selector}"`);

    this.iterator.forEachNode(this.filter.SHOW_ELEMENT, node => { // each
      this.unwrapMatches(node);
    }, node => { // filter
      return DOMIterator.matches(node, selector) && !(exclude && this.excluded(node));
    }, this.opt.done);
  }

  /**
   * Registers a Highlight object using the HighlightRegistry
   */
  registerHighlight() {
    const highlight = this.opt.highlight;

    if (highlight) {
      const name = this.opt.highlightName,
        // eslint-disable-next-line
        registry = CSS.highlights;

      if (this.rangeArray.length) {
        registry.delete(name);

        if (highlight.size) {
          highlight.forEach(range => {
            this.rangeArray.push(range);
          });
          highlight.clear();
        }

        this.rangeArray.sort((a, b) => a.absoluteOffset - b.absoluteOffset);
        this.rangeArray.forEach(range => {
          highlight.add(range);
        });
        this.rangeArray = [];
      }
      if (highlight.size) registry.set(name, highlight);
    }
  }
}

export default Mark;