'use strict';

const AttributeMap = require('./AttributeMap');
const Changeset = require('./Changeset');
const ChangesetUtils = require('./ChangesetUtils');
const attributes = require('./attributes');
const _ = require('./underscore');

const lineMarkerAttribute = 'lmkr';

// Some of these attributes are kept for compatibility purposes.
// Not sure if we need all of them
const DEFAULT_LINE_ATTRIBUTES = ['author', 'lmkr', 'insertorder', 'start'];

// If one of these attributes are set to the first character of a
// line it is considered as a line attribute marker i.e. attributes
// set on this marker are applied to the whole line.
// The list attribute is only maintained for compatibility reasons
const lineAttributes = [lineMarkerAttribute, 'list'];

/*
  The Attribute manager builds changesets based on a document
  representation for setting and removing range or line-based attributes.

  @param rep the document representation to be used
  @param applyChangesetCallback this callback will be called
    once a changeset has been built.


  A document representation contains
  - an array `alines` containing 1 attributes string for each line
  - an Attribute pool `apool`
  - a SkipList `lines` containing the text lines of the document.
*/

const AttributeManager = function (rep, applyChangesetCallback) {
  this.rep = rep;
  this.applyChangesetCallback = applyChangesetCallback;
  this.author = '';

  // If the first char in a line has one of the following attributes
  // it will be considered as a line marker
};

AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES;
AttributeManager.lineAttributes = lineAttributes;

AttributeManager.prototype = _(AttributeManager.prototype).extend({

  applyChangeset(changeset) {
    if (!this.applyChangesetCallback) return changeset;

    const cs = changeset.toString();
    if (!Changeset.isIdentity(cs)) {
      this.applyChangesetCallback(cs);
    }

    return changeset;
  },

  /*
    Sets attributes on a range
    @param start [row, col] tuple pointing to the start of the range
    @param end [row, col] tuple pointing to the end of the range
    @param attribs: an array of attributes
  */
  setAttributesOnRange(start, end, attribs) {
    if (start[0] < 0) throw new RangeError('selection start line number is negative');
    if (start[1] < 0) throw new RangeError('selection start column number is negative');
    if (end[0] < 0) throw new RangeError('selection end line number is negative');
    if (end[1] < 0) throw new RangeError('selection end column number is negative');
    if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) {
      throw new RangeError('selection ends before it starts');
    }

    // instead of applying the attributes to the whole range at once, we need to apply them
    // line by line, to be able to disregard the "*" used as line marker. For more details,
    // see https://github.com/ether/etherpad-lite/issues/2772
    let allChangesets;
    for (let row = start[0]; row <= end[0]; row++) {
      const [startCol, endCol] = this._findRowRange(row, start, end);
      const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs);

      // compose changesets of all rows into a single changeset
      // as the range might not be continuous
      // due to the presence of line markers on the rows
      if (allChangesets) {
        allChangesets = Changeset.compose(
            allChangesets.toString(), rowChangeset.toString(), this.rep.apool);
      } else {
        allChangesets = rowChangeset;
      }
    }

    return this.applyChangeset(allChangesets);
  },

  _findRowRange(row, start, end) {
    if (row < start[0] || row > end[0]) throw new RangeError(`line ${row} not in selection`);
    if (row >= this.rep.lines.length()) throw new RangeError(`selected line ${row} does not exist`);

    // Subtract 1 for the end-of-line '\n' (it is never selected).
    const lineLength =
        this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1;
    const markerWidth = this.lineHasMarker(row) ? 1 : 0;
    if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`);

    if (start[1] < 0) throw new RangeError('selection starts at negative column');
    const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0);
    if (startCol > lineLength) throw new RangeError('selection starts after line end');

    if (end[1] < 0) throw new RangeError('selection ends at negative column');
    const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength);
    if (endCol > lineLength) throw new RangeError('selection ends after line end');
    if (startCol > endCol) throw new RangeError('selection ends before it starts');

    return [startCol, endCol];
  },

  /**
   * Sets attributes on a range, by line
   * @param row the row where range is
   * @param startCol column where range starts
   * @param endCol column where range ends (one past the last selected column)
   * @param attribs an array of attributes
   */
  _setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
    const builder = Changeset.builder(this.rep.lines.totalWidth());
    ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]);
    ChangesetUtils.buildKeepRange(
        this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool);
    return builder;
  },

  /*
    Returns if the line already has a line marker
    @param lineNum: the number of the line
  */
  lineHasMarker(lineNum) {
    return lineAttributes.find(
        (attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined;
  },

  /*
    Gets a specified attribute on a line
    @param lineNum: the number of the line to set the attribute for
    @param attributeKey: the name of the attribute to get, e.g. list
  */
  getAttributeOnLine(lineNum, attributeName) {
    // get  `attributeName` attribute of first char of line
    const aline = this.rep.alines[lineNum];
    if (!aline) return '';
    const [op] = Changeset.deserializeOps(aline);
    if (op == null) return '';
    return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || '';
  },

  /*
    Gets all attributes on a line
    @param lineNum: the number of the line to get the attribute for
  */
  getAttributesOnLine(lineNum) {
    // get attributes of first char of line
    const aline = this.rep.alines[lineNum];
    if (!aline) return [];
    const [op] = Changeset.deserializeOps(aline);
    if (op == null) return [];
    return [...attributes.attribsFromString(op.attribs, this.rep.apool)];
  },

  /*
    Gets a given attribute on a selection
    @param attributeName
    @param prevChar
    returns true or false if an attribute is visible in range
  */
  getAttributeOnSelection(attributeName, prevChar) {
    const rep = this.rep;
    if (!(rep.selStart && rep.selEnd)) return;
    // If we're looking for the caret attribute not the selection
    // has the user already got a selection or is this purely a caret location?
    const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]);
    if (isNotSelection) {
      if (prevChar) {
        // If it's not the start of the line
        if (rep.selStart[1] !== 0) {
          rep.selStart[1]--;
        }
      }
    }

    const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString();
    const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`);
    const hasIt = (attribs) => withItRegex.test(attribs);

    const rangeHasAttrib = (selStart, selEnd) => {
      // if range is collapsed -> no attribs in range
      if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false;

      if (selStart[0] !== selEnd[0]) { // -> More than one line selected
        // from selStart to the end of the first line
        let hasAttrib = rangeHasAttrib(
            selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]);

        // for all lines in between
        for (let n = selStart[0] + 1; n < selEnd[0]; n++) {
          hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]);
        }

        // for the last, potentially partial, line
        hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);

        return hasAttrib;
      }

      // Logic tells us we now have a range on a single line

      const lineNum = selStart[0];
      const start = selStart[1];
      const end = selEnd[1];
      let hasAttrib = true;

      let indexIntoLine = 0;
      for (const op of Changeset.deserializeOps(rep.alines[lineNum])) {
        const opStartInLine = indexIntoLine;
        const opEndInLine = opStartInLine + op.chars;
        if (!hasIt(op.attribs)) {
          // does op overlap selection?
          if (!(opEndInLine <= start || opStartInLine >= end)) {
            // since it's overlapping but hasn't got the attrib -> range hasn't got it
            hasAttrib = false;
            break;
          }
        }
        indexIntoLine = opEndInLine;
      }

      return hasAttrib;
    };
    return rangeHasAttrib(rep.selStart, rep.selEnd);
  },

  /*
    Gets all attributes at a position containing line number and column
    @param lineNumber starting with zero
    @param column starting with zero
    returns a list of attributes in the format
    [ ["key","value"], ["key","value"], ...  ]
  */
  getAttributesOnPosition(lineNumber, column) {
    // get all attributes of the line
    const aline = this.rep.alines[lineNumber];

    if (!aline) {
      return [];
    }

    // we need to sum up how much characters each operations take until the wanted position
    let currentPointer = 0;

    for (const currentOperation of Changeset.deserializeOps(aline)) {
      currentPointer += currentOperation.chars;
      if (currentPointer <= column) continue;
      return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)];
    }
    return [];
  },

  /*
    Gets all attributes at caret position
    if the user selected a range, the start of the selection is taken
    returns a list of attributes in the format
    [ ["key","value"], ["key","value"], ...  ]
  */
  getAttributesOnCaret() {
    return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]);
  },

  /*
    Sets a specified attribute on a line
    @param lineNum: the number of the line to set the attribute for
    @param attributeKey: the name of the attribute to set, e.g. list
    @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level)

  */
  setAttributeOnLine(lineNum, attributeName, attributeValue) {
    let loc = [0, 0];
    const builder = Changeset.builder(this.rep.lines.totalWidth());
    const hasMarker = this.lineHasMarker(lineNum);

    ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0]));

    if (hasMarker) {
      ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [
        [attributeName, attributeValue],
      ], this.rep.apool);
    } else {
      // add a line marker
      builder.insert('*', [
        ['author', this.author],
        ['insertorder', 'first'],
        [lineMarkerAttribute, '1'],
        [attributeName, attributeValue],
      ], this.rep.apool);
    }

    return this.applyChangeset(builder);
  },

  /**
   * Removes a specified attribute on a line
   *  @param lineNum the number of the affected line
   *  @param attributeName the name of the attribute to remove, e.g. list
   *  @param attributeValue if given only attributes with equal value will be removed
   */
  removeAttributeOnLine(lineNum, attributeName, attributeValue) {
    const builder = Changeset.builder(this.rep.lines.totalWidth());
    const hasMarker = this.lineHasMarker(lineNum);
    let found = false;

    const attribs = this.getAttributesOnLine(lineNum).map((attrib) => {
      if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) {
        found = true;
        return [attrib[0], ''];
      } else if (attrib[0] === 'author') {
        // update last author to make changes to line attributes on this line
        return [attrib[0], this.author];
      }
      return attrib;
    });

    if (!found) {
      return;
    }

    ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]);

    const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1])
        .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value();

    // if we have marker and any of attributes don't need to have marker. we need delete it
    if (hasMarker && !countAttribsWithMarker) {
      ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]);
    } else {
      ChangesetUtils.buildKeepRange(
          this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool);
    }

    return this.applyChangeset(builder);
  },

  /*
     Toggles a line attribute for the specified line number
     If a line attribute with the specified name exists with any value it will be removed
     Otherwise it will be set to the given value
     @param lineNum: the number of the line to toggle the attribute for
     @param attributeKey: the name of the attribute to toggle, e.g. list
     @param attributeValue: the value to pass to the attribute (e.g. indention level)
  */
  toggleAttributeOnLine(lineNum, attributeName, attributeValue) {
    return this.getAttributeOnLine(lineNum, attributeName)
      ? this.removeAttributeOnLine(lineNum, attributeName)
      : this.setAttributeOnLine(lineNum, attributeName, attributeValue);
  },

  hasAttributeOnSelectionOrCaretPosition(attributeName) {
    const hasSelection = (
      (this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1])
    );
    let hasAttrib;
    if (hasSelection) {
      hasAttrib = this.getAttributeOnSelection(attributeName);
    } else {
      const attributesOnCaretPosition = this.getAttributesOnCaret();
      const allAttribs = [].concat(...attributesOnCaretPosition); // flatten
      hasAttrib = allAttribs.includes(attributeName);
    }
    return hasAttrib;
  },
});

module.exports = AttributeManager;
