'use strict';

Object.defineProperty(exports, '__esModule', {
  value: true
});

var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }

var _lodash = require('lodash');

var _lodash2 = _interopRequireDefault(_lodash);

var _reactLibInvariant = require('react/lib/invariant');

var _reactLibInvariant2 = _interopRequireDefault(_reactLibInvariant);

var _swarmLibSpec = require('swarm/lib/Spec');

var _swarmLibSpec2 = _interopRequireDefault(_swarmLibSpec);

var _swarmLibSyncable = require('swarm/lib/Syncable');

var _swarmLibSyncable2 = _interopRequireDefault(_swarmLibSyncable);

var _utils = require('./utils');

var Char = (function () {
  function Char(id, char, deletedIds, attributes) {
    _classCallCheck(this, Char);

    this.id = id;
    this.char = char;
    this.deletedIds = _lodash2['default'].isArray(deletedIds) ? new Set(deletedIds) : deletedIds;
    this._attributes = attributes ? Object.freeze(attributes) : null;
  }

  _createClass(Char, [{
    key: 'copyOfAttributes',
    value: function copyOfAttributes() {
      return this._attributes ? _lodash2['default'].clone(this._attributes) : null;
    }
  }, {
    key: 'toString',
    value: function toString() {
      var char = this.char.replace('\n', '↵').replace(' ', '␣');
      return char + ' (' + this.id + ')}';
    }
  }, {
    key: 'attributes',
    get: function get() {
      return this._attributes;
    },
    set: function set(attrs) {
      this._attributes = attrs ? Object.freeze(attrs) : null;
    }
  }]);

  return Char;
})();

var BASE_CHAR = new Char('00000+swarm', '', null, null);
var EOF = -1;

/**
 * Contains the textual data and corresponding lamport timestamps (ids) for each character. Each character
 * has a primary id, but may have secondary ids in a Set representing deleted characters at that position. In
 * addition, each character has a list of other "rich" attributes, such as bold, color, and so forth.
 *
 * Currently the data storage is in regular JS arrays, but perhaps we could use immutable-js:
 *  - (possible) faster or more consistent insertion performance, splice performance is implementation dependent
 *  - blazing fast reference equality comparisons
 */

var TextData = (function () {
  function TextData() {
    _classCallCheck(this, TextData);

    BASE_CHAR.deletedIds = new Set();
    this.chars = [BASE_CHAR];
  }

  /**
   * This is based on the Text.js demo class from the SwarmJS library by @gritzko, with the following primary
   * differences:
   *
   * 1) The `weave` was replaced with an array of Char objects within TextData.
   *
   * 2) The `weave` contained characters and then backspace characters for deletions. Deletions are now stored in
   * per-character buckets so that they don't have to be constantly filtered out of the weave. This is also quite
   * amenable to tombstone clearing.
   *
   * 3) Added the ability to store rich-text and other attributes in the Char objects.
   *
   * 4) Created an API to get/set changes via "deltas". The delta format is from https://github.com/ottypes/rich-text.
   * This provides some limited support to applications that wish to convert CRDT ops to/from operational transform
   * ops. This support is not currently used by Ritzy and may be removed in the future.
   *
   * Note that for non-basic multilingual plane (BMP) characters (rare!) using string.length could be wrong in
   * Javascript. See https://mathiasbynens.be/notes/javascript-encoding.
   */

  _createClass(TextData, [{
    key: 'len',
    value: function len() {
      return this.chars.length;
    }
  }, {
    key: 'getChar',
    value: function getChar(pos) {
      (0, _reactLibInvariant2['default'])(pos < this.len(), 'Index ' + pos + ' out of bounds.');
      // TODO Char should be immutable so that it cannot be modified outside of this class, use Immutable.js Record?
      return this.chars[pos];
    }
  }, {
    key: 'insertChar',
    value: function insertChar(pos, char, id, attributes) {
      (0, _reactLibInvariant2['default'])(pos !== 0, 'Cannot insert at position 0.');
      (0, _reactLibInvariant2['default'])(pos <= this.len(), 'Index ' + pos + ' out of bounds.');
      this.chars.splice(pos, 0, new Char(id, char, null, attributes ? attributes : null));
    }
  }, {
    key: 'deleteChar',
    value: function deleteChar(pos) {
      (0, _reactLibInvariant2['default'])(pos !== 0, 'Cannot delete position 0.');
      (0, _reactLibInvariant2['default'])(pos < this.len(), 'Index ' + pos + ' out of bounds.');
      var previousChar = this.chars[pos - 1];
      var deletedChar = this.chars.splice(pos, 1)[0];
      if (!previousChar.deletedIds) {
        previousChar.deletedIds = new Set();
      }
      previousChar.deletedIds.add(deletedChar.id);
      if (deletedChar.deletedIds) {
        (0, _utils.pushSet)(previousChar.deletedIds, deletedChar.deletedIds);
      }
    }
  }, {
    key: 'setCharAttr',
    value: function setCharAttr(pos, attributes) {
      (0, _reactLibInvariant2['default'])(pos !== 0, 'Cannot set attributes of position 0.');
      (0, _reactLibInvariant2['default'])(pos < this.len(), 'Index ' + pos + ' out of bounds.');

      this.chars[pos].attributes = _lodash2['default'].clone(attributes);
    }
  }, {
    key: 'matches',
    value: function matches(pos, ids, includeDeleted) {
      (0, _reactLibInvariant2['default'])(pos < this.len(), 'Index out of bounds.');
      includeDeleted = includeDeleted !== false;
      if (_lodash2['default'].isArray(ids) || ids.iterator) {
        if (!ids.iterator) {
          ids = new Set(ids);
        }
        if (ids.has(this.chars[pos].id)) {
          return true;
        }
        if (includeDeleted && this.chars[pos].deletedIds) {
          return (0, _utils.setIntersection)(this.chars[pos].deletedIds, ids).length > 0;
        }
      } else {
        if (ids === this.chars[pos].id) {
          return true;
        }
        if (includeDeleted && this.chars[pos].deletedIds) {
          return this.chars[pos].deletedIds.has(ids);
        }
      }
      return false;
    }
  }, {
    key: 'matchCount',
    value: function matchCount(pos, ids, includeDeleted) {
      (0, _reactLibInvariant2['default'])(pos < this.len(), 'Index out of bounds.');
      includeDeleted = includeDeleted !== false;
      var matches = 0;
      if (_lodash2['default'].isArray(ids) || ids.iterator) {
        if (!ids.iterator) {
          ids = new Set(ids);
        }
        if (ids.has(this.chars[pos].id)) {
          matches += 1;
        }
        if (includeDeleted && this.chars[pos].deletedIds) {
          matches += (0, _utils.setIntersection)(this.chars[pos].deletedIds, ids).length;
        }
      } else {
        if (ids === this.chars[pos].id) {
          matches += 1;
        }
        if (includeDeleted && this.chars[pos].deletedIds && this.chars[pos].deletedIds.has(ids)) {
          matches += 1;
        }
      }
      return matches;
    }
  }, {
    key: 'text',
    value: function text() {
      return this.chars.map(function (c) {
        return c.char;
      }).join('');
    }
  }]);

  return TextData;
})();

var Text = _swarmLibSyncable2['default'].extend('Text', {
  // naive uncompressed CT weave implementation based on Swarm Text.js
  defaults: {
    data: { type: TextData },
    _oplog: Object
  },

  ops: {
    insert: function insert(spec, ins, src) {
      // eslint-disable-line no-unused-vars
      var vt = spec.token('!'),
          v = vt.bare;
      var ts = v.substr(0, 5),
          seq = v.substr(5) || '00';
      var seqi = _swarmLibSpec2['default'].base2int(seq);
      var genTs = undefined;
      var insertKeys = ins ? Object.keys(ins) : [];
      for (var i = 0; i < this.data.len(); i++) {
        for (var j = 0; j < insertKeys.length; j++) {
          var insKey = insertKeys[j];
          if (this.data.matches(i, insKey)) {
            var str = ins[insKey].value;
            var attrs = ins[insKey].attributes;
            var k = i + 1;
            while (k < this.data.len() && this.data.getChar(k).id > vt.body) {
              k++;
            }
            if (k > i + 1) {
              // concurrent edits
              var newid = this.data.getChar(k - 1).id;
              ins[newid] = ins[insKey];
              delete ins[insKey];
            } else {
              for (var l = 0; l < str.length; l++) {
                genTs = ts + (seqi ? _swarmLibSpec2['default'].int2base(seqi++, 2) : '') + '+' + vt.ext;
                this.data.insertChar(i + l + 1, str.charAt(l), genTs, attrs);
                if (!seqi) {
                  seqi = 1; // FIXME repeat ids, double insert
                }
              }
              i += str.length;
            }
          }
        }
      }
      if (genTs) {
        this._host.clock.checkTimestamp(genTs);
      }
    },

    remove: function remove(spec, rm, src) {
      // eslint-disable-line no-unused-vars
      //let v = spec.version()
      if (!rm) return;
      var rmKeys = Object.keys(rm);
      for (var i = 1; i < this.data.len(); i++) {
        if (this.data.matches(i, rmKeys)) {
          this.data.deleteChar(i);
          i -= 1;
        }
      }
    },

    /**
     * Set attributes for the given chars. Attributes are overwritten, therefore it is client code's
     * responsibility to "merge" existing attributes with new ones.
     */
    setAttributes: function setAttributes(spec, attrs, src) {
      // eslint-disable-line no-unused-vars
      if (!attrs) return;
      var attrKeys = Object.keys(attrs);
      for (var i = 1; i < this.data.len(); i++) {
        for (var j = 0; j < attrKeys.length; j++) {
          if (this.data.matches(i, attrKeys[j], false)) {
            this.data.setCharAttr(i, attrs[attrKeys[j]]);
          }
        }
      }
    }
  },

  text: function text() {
    return this.data.text();
  },

  /**
   * A delta is based on the operational transform rich text type. See https://github.com/ottypes/rich-text.
   * @param delta
   */
  applyDelta: function applyDelta(delta) {
    var rm = null;
    var ins = null;
    var pos = 1; // skip \n #00000+swarm

    for (var i = 0; i < delta.length; i++) {
      var op = delta[i];
      if (op.insert) {
        (0, _reactLibInvariant2['default'])(pos > 0, 'Cannot insert at position 0.');
        if (!ins) ins = {};
        ins[this.data.getChar(pos - 1).id] = {
          value: op.insert,
          attributes: op.attributes
        };
        // we don't increment pos here because the insert hasn't actually happened yet
      }
      if (op['delete']) {
        (0, _reactLibInvariant2['default'])(pos > 0, 'Cannot delete position 0.');
        if (!rm) rm = {};
        var rmcount = op['delete'];
        for (var j = 0; j < rmcount; j++) {
          rm[this.data.getChar(pos).id] = true;
          pos += 1;
        }
      }
      if (op.retain) {
        pos += op.retain;
      }
    }

    if (rm) this.remove(rm);
    if (ins) this.insert(ins);
  },

  /**
   * Obtain a delta based on an insert operation. Note that this must be run *after* the insert has already
   * occurred on the replica. This can be used to obtain deltas for updating a local editor based on an op received
   * from the replica event system.
   * @param op
   * @returns {Array}
   */
  deltaFromInsert: function deltaFromInsert(op) {
    var delta = [];
    var foundCount = 0;
    var opKeys = op ? Object.keys(op) : [];
    var lastInsert = 0;
    for (var i = 0; i < this.data.len(); i++) {
      for (var j = 0; j < opKeys.length; j++) {
        var opKey = opKeys[j];
        if (this.data.matches(i, opKey)) {
          if (i - lastInsert > 0) delta.push({ retain: i - lastInsert });
          var str = op[opKey].value;
          var deltaOp = { insert: str };
          var attrs = op[opKey].attributes;
          if (attrs) {
            deltaOp.attributes = attrs;
          }
          delta.push(deltaOp);
          lastInsert = i + str.length;
          foundCount += 1;
          if (foundCount >= opKeys.length) {
            return delta;
          }
        }
      }
    }
    return delta;
  },

  /**
   * Obtain a delta based on a remove operation. Note that this must be run *after* the remove has already
   * occurred on the replica. This can be used to obtain deltas for updating a local editor based on an op received
   * from the replica event system.
   * @param op
   * @returns {Array}
   */
  deltaFromRemove: function deltaFromRemove(op) {
    var delta = [];
    var foundCount = 0;
    var opKeys = Object.keys(op);
    var lastRemove = 0;
    for (var i = 0; i < this.data.len(); i++) {
      var matchCount = this.data.matchCount(i, opKeys);
      if (matchCount > 0) {
        if (i - lastRemove > 0) delta.push({ retain: i - lastRemove });
        // since the delete has already occurred we need to use the number of matched ids at the current char
        delta.push({ 'delete': matchCount });
        lastRemove = i;
        foundCount += matchCount;
        if (foundCount >= opKeys.length) {
          return delta;
        }
      }
    }
    return delta;
  },

  /**
   * Insert chars with optional attributes at a given position.
   * @param {Char} char The position at which to insert.
   * @param {string} value The string value to insert.
   * @param {object} [attributes] Attributes to set, or no attributes if not set. The attributes are
   *   cloned before setting so that they cannot be modified by simply changing the object reference.
   *   This type of change would not propagate through the replica.
   */
  insertCharsAt: function insertCharsAt(char, value, attributes) {
    var ins = {};
    ins[char.id] = {
      value: value,
      attributes: attributes
    };
    this.insert(ins);
  },

  /**
   * Delete the given chars.
   * @param {Char|Char[]} chars
   */
  rmChars: function rmChars(chars) {
    if (!chars) return;
    var rm = {};
    if (_lodash2['default'].isArray(chars)) {
      for (var i = 0; i < chars.length; i++) {
        rm[chars[i].id] = true;
      }
    } else {
      rm[chars.id] = true;
    }
    this.remove(rm);
  },

  /**
   * Sets new text. All current text contents are deleted (though the deleted ids remain).
   * @param {string} newText
   * @param {object} [attributes]
   */
  set: function set(newText, attributes) {
    this.rmChars(this.getTextRange(BASE_CHAR));
    this.insertCharsAt(BASE_CHAR, newText, attributes);
  },

  /**
   * Gets the length of the current replica data, including the BASE_CHAR (the length of the actual data).
   * @returns {number}
   */
  len: function len() {
    return this.data.len();
  },

  /**
   * Gets the char for the given char or id. Can be used to "refresh" the char information which is
   * a snapshot with the latest replica information.
   * @param {Char|number} charOrId
   * @returns {*}
   */
  getChar: function getChar(charOrId) {
    return this.getCharRelativeTo(charOrId, 0, 'error');
  },

  /**
   * Gets the char at the given position. Position 0 is always the BASE_CHAR. An Error is thrown
   * if the position is out of bounds.
   * @param {number} pos
   * @returns {*}
   */
  getCharAt: function getCharAt(pos) {
    return this.data.getChar(pos);
  },

  /**
   * Returns the index of a given char or ID. Index 0 is always the BASE_CHAR. If the char is not
   * found, returns -1.
   * @param {Char|number} charOrId
   * @param {boolean} [includeDeleted=true] Whether to include deletec chars in the match.
   * @returns number
   */
  indexOf: function indexOf(charOrId, includeDeleted) {
    (0, _reactLibInvariant2['default'])(charOrId, 'From char must be defined.');
    var id = _lodash2['default'].has(charOrId, 'id') ? charOrId.id : charOrId;
    for (var i = 0; i < this.data.len(); i++) {
      if (this.data.matches(i, id, includeDeleted)) return i;
    }
    return -1;
  },

  /**
   * Gets a character relative to another character. Relative can be positive or
   * negative. If the position becomes out of bound, the position can wrap, limit to
   * the end, or error (depending on the last parameter).
   * @param {Char|string} charOrId
   * @param {number} relative
   * @param {string} [wrap='wrap'] The behavior when the index is out of bounds. Must be one
   *   of 'wrap', 'limit', 'eof', or 'error'. 'eof' returns EOF (-1) if past the end.
   * @return {*}
   */
  getCharRelativeTo: function getCharRelativeTo(charOrId, relative, wrap) {
    (0, _reactLibInvariant2['default'])(charOrId, 'Char must be defined.');
    if (_lodash2['default'].isUndefined(relative)) relative = 0;
    if (_lodash2['default'].isUndefined(wrap)) wrap = 'wrap';

    if (charOrId === EOF) {
      if (relative > 0 && (wrap === 'limit' || wrap === 'eof')) return EOF;else if (relative > 0 && wrap === 'error') throw new Error('Index out of bounds, past EOF by: ' + relative);else if (relative > 0 && wrap === 'wrap') {
        charOrId = this.data.getChar(this.data.len() - 1);
      } else {
        charOrId = this.data.getChar(this.data.len() - 1);
        relative += 1;
      }
    }

    var id = _lodash2['default'].has(charOrId, 'id') ? charOrId.id : charOrId;
    for (var i = 0; i < this.data.len(); i++) {
      if (this.data.matches(i, id)) {
        var index = i + relative;
        if (wrap === 'wrap') {
          if (index < 0) index = this.data.len() + index;else if (index >= this.data.len()) index = index - this.data.len();
        } else if (wrap === 'limit') {
          if (index < 0) index = 0;else if (index >= this.data.len()) index = this.data.len() - 1;
        } else if (wrap === 'eof') {
          if (index < 0) index = 0;else if (index >= this.data.len()) return EOF;
        } else if (wrap === 'error') {
          if (index < 0 || index >= this.data.len()) {
            throw new Error('Index out of bounds: ' + index);
          }
        } else {
          throw new Error('Undefined wrap value: ' + wrap);
        }
        return this.getCharAt(index);
      }
    }
  },

  /**
   * Gets all the chars from a given ID (exclusive) to a given ID (inclusive). The length of the returned
   * range is going to be `pos(toChar) - pos(fromChar)`.
   * @param {Char|string} fromCharOrId
   * @param {Char|string} [toCharOrId = last] If the to char does not exist, then to char is the last char.
   * @returns {Array}
   */
  getTextRange: function getTextRange(fromCharOrId, toCharOrId) {
    (0, _reactLibInvariant2['default'])(fromCharOrId, 'From char must be defined.');
    var fromMatched = false;
    var chars = [];
    var fromId = _lodash2['default'].has(fromCharOrId, 'id') ? fromCharOrId.id : fromCharOrId;
    var toId = undefined;
    if (!_lodash2['default'].isUndefined(toCharOrId)) {
      toId = _lodash2['default'].has(toCharOrId, 'id') ? toCharOrId.id : toCharOrId;
    }

    if (fromId === toId) {
      return chars;
    }
    for (var i = 0; i < this.data.len(); i++) {
      if (!fromMatched && this.data.matches(i, fromId)) {
        // the fromId is exclusive
        fromMatched = true;
        if (fromId === toId) {
          chars.push(this.getCharAt(i));
          return chars;
        }
      } else if (toId && this.data.matches(i, toId)) {
        (0, _reactLibInvariant2['default'])(fromMatched, 'From id must precede To id.');
        chars.push(this.getCharAt(i));
        return chars;
      } else if (fromMatched) {
        chars.push(this.getCharAt(i));
      }
    }
    return chars;
  },

  /**
   * Compares the position of two chars. Follows the contract of Java Comparator
   * (http://docs.oracle.com/javase/8/docs/api/java/util/Comparator.html#compare-T-T-) and returns
   * a negative integer, zero, or a positive integer as the first argument is positioned before,
   * equal to, or positioned after the second.
   * @param {Char|string} charOrId1
   * @param {Char|string} charOrId2
   * @return {number}
   */
  compareCharPos: function compareCharPos(charOrId1, charOrId2) {
    (0, _reactLibInvariant2['default'])(charOrId1, 'First char must be defined.');
    (0, _reactLibInvariant2['default'])(charOrId2, 'Second char must be defined.');

    if (charOrId1 === EOF && charOrId2 === EOF) return 0;else if (charOrId1 === EOF) return 1;else if (charOrId2 === EOF) return -1;

    var char1Id = _lodash2['default'].has(charOrId1, 'id') ? charOrId1.id : charOrId1;
    var char2Id = _lodash2['default'].has(charOrId2, 'id') ? charOrId2.id : charOrId2;

    var seen1 = false;
    var seen1Index = undefined;
    var seen2 = false;
    var seen2Index = undefined;
    for (var i = 0; i < this.data.len(); i++) {
      if (!seen1 && this.data.matches(i, char1Id)) {
        seen1 = true;
        seen1Index = i;
        // special case same char
        if (char1Id === char2Id) {
          return 0;
        }
      }
      if (!seen2 && this.data.matches(i, char2Id)) {
        seen2 = true;
        seen2Index = i;
      }
      if (seen1 && seen2) {
        if (seen1Index < seen2Index) return -1;else if (seen1Index === seen2Index) return 0;else return 1;
      }
    }
    throw new Error('One or both chars were not found.');
  }
});

exports['default'] = Text;
exports.BASE_CHAR = BASE_CHAR;
exports.EOF = EOF;
exports.Char = Char;
exports.TextData = TextData;