// Copyright (C) 2016 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(function() {
  'use strict';

  // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
  var RANGE_HIGHLIGHT = 'range';
  var HOVER_HIGHLIGHT = 'rangeHighlight';

  Polymer({
    is: 'gr-diff-highlight',

    properties: {
      comments: Object,
      enabled: {
        type: Boolean,
        observer: '_enabledChanged',
      },
      loggedIn: Boolean,
      _cachedDiffBuilder: Object,
      _enabledListeners: {
        type: Object,
        value: function() {
          return {
            'comment-discard': '_handleCommentDiscard',
            'comment-mouse-out': '_handleCommentMouseOut',
            'comment-mouse-over': '_handleCommentMouseOver',
            'create-comment': '_createComment',
            'render': '_handleRender',
            'show-context': '_handleShowContext',
            'thread-discard': '_handleThreadDiscard',
          };
        },
      },
    },

    get diffBuilder() {
      if (!this._cachedDiffBuilder) {
        this._cachedDiffBuilder =
            Polymer.dom(this).querySelector('gr-diff-builder');
      }
      return this._cachedDiffBuilder;
    },

    detached: function() {
      this.enabled = false;
    },

    _enabledChanged: function() {
      if (this.enabled) {
        this.listen(document, 'selectionchange', '_handleSelectionChange');
      } else {
        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
      }
      for (var eventName in this._enabledListeners) {
        var methodName = this._enabledListeners[eventName];
        if (this.enabled) {
          this.listen(this, eventName, methodName);
        } else {
          this.unlisten(this, eventName, methodName);
        }
      }
    },

    isRangeSelected: function() {
      return !!this.$$('gr-selection-action-box');
    },

    _handleThreadDiscard: function(e) {
      var comment = e.detail.lastComment;
      // Comment Element was removed from DOM already.
      if (comment.range) {
        this._renderCommentRange(comment, e.target);
      }
    },

    _handleCommentDiscard: function(e) {
      var comment = e.detail.comment;
      if (comment.range) {
        this._renderCommentRange(comment, e.target);
      }
    },

    _handleSelectionChange: function() {
      // Can't use up or down events to handle selection started and/or ended in
      // in comment threads or outside of diff.
      // Debounce removeActionBox to give it a chance to react to click/tap.
      this._removeActionBoxDebounced();
      this.debounce('selectionChange', this._handleSelection, 200);
    },

    _handleRender: function() {
      this._applyAllHighlights();
    },

    _handleShowContext: function() {
      // TODO (viktard): Re-render expanded sections only.
      this._applyAllHighlights();
    },

    _handleCommentMouseOver: function(e) {
      var comment = e.detail.comment;
      var range = comment.range;
      if (!range) {
        return;
      }
      var lineEl = this.diffBuilder.getLineElByChild(e.target);
      var side = this.diffBuilder.getSideByLineEl(lineEl);
      this._applyRangedHighlight(
          HOVER_HIGHLIGHT, range.start_line, range.start_character,
          range.end_line, range.end_character, side);
    },

    _handleCommentMouseOut: function(e) {
      var comment = e.detail.comment;
      var range = comment.range;
      if (!range) {
        return;
      }
      var lineEl = this.diffBuilder.getLineElByChild(e.target);
      var side = this.diffBuilder.getSideByLineEl(lineEl);
      var contentEls = this.diffBuilder.getContentsByLineRange(
          range.start_line, range.end_line, side);
      contentEls.forEach(function(content) {
        Polymer.dom(content).querySelectorAll('.' + HOVER_HIGHLIGHT).forEach(
            function(el) {
              el.classList.remove(HOVER_HIGHLIGHT);
              el.classList.add(RANGE_HIGHLIGHT);
            });
      }, this);
    },

    /**
     * Convert DOM Range selection to concrete numbers (line, column, side).
     * Moves range end if it's not inside td.content.
     * Returns null if selection end is not valid (outside of diff).
     *
     * @param {Node} node td.content child
     * @param {number} offset offset within node
     * @return {{
     *   node: Node,
     *   side: string,
     *   line: Number,
     *   column: Number
     * }}
     */
    _normalizeSelectionSide: function(node, offset) {
      var column;
      if (!this.contains(node)) {
        return;
      }
      var lineEl = this.diffBuilder.getLineElByChild(node);
      if (!lineEl) {
        return;
      }
      var side = this.diffBuilder.getSideByLineEl(lineEl);
      if (!side) {
        return;
      }
      var line = this.diffBuilder.getLineNumberByChild(lineEl);
      if (!line) {
        return;
      }
      var contentText = this.diffBuilder.getContentByLineEl(lineEl);
      if (!contentText) {
        return;
      }
      var contentTd = contentText.parentElement;
      if (!contentTd.contains(node)) {
        node = contentText;
        column = 0;
      } else {
        var thread = contentTd.querySelector('gr-diff-comment-thread');
        if (thread && thread.contains(node)) {
          column = this._getLength(contentText);
          node = contentText;
        } else {
          column = this._convertOffsetToColumn(node, offset);
        }
      }

      return {
        node: node,
        side: side,
        line: line,
        column: column,
      };
    },

    _handleSelection: function() {
      var selection = window.getSelection();
      if (selection.rangeCount != 1) {
        return;
      }
      var range = selection.getRangeAt(0);
      if (range.collapsed) {
        return;
      }
      var start =
          this._normalizeSelectionSide(range.startContainer, range.startOffset);
      if (!start) {
        return;
      }
      var end =
          this._normalizeSelectionSide(range.endContainer, range.endOffset);
      if (!end) {
        return;
      }
      if (start.side !== end.side ||
          end.line < start.line ||
          (start.line === end.line && start.column === end.column)) {
        return;
      }

      // TODO (viktard): Drop empty first and last lines from selection.

      var actionBox = document.createElement('gr-selection-action-box');
      Polymer.dom(this.root).appendChild(actionBox);
      actionBox.range = {
        startLine: start.line,
        startChar: start.column,
        endLine: end.line,
        endChar: end.column,
      };
      actionBox.side = start.side;
      if (start.line === end.line) {
        actionBox.placeAbove(range);
      } else if (start.node instanceof Text) {
        actionBox.placeAbove(start.node.splitText(start.column));
        start.node.parentElement.normalize(); // Undo splitText from above.
      } else if (start.node.classList.contains('content') &&
                 start.node.firstChild) {
        actionBox.placeAbove(start.node.firstChild);
      } else {
        actionBox.placeAbove(start.node);
      }
    },

    _renderCommentRange: function(comment, el) {
      var lineEl = this.diffBuilder.getLineElByChild(el);
      if (!lineEl) {
        return;
      }
      var side = this.diffBuilder.getSideByLineEl(lineEl);
      this._rerenderByLines(
          comment.range.start_line, comment.range.end_line, side);
    },

    _createComment: function(e) {
      this._removeActionBox();
      var side = e.detail.side;
      var range = e.detail.range;
      if (!range) {
        return;
      }
      this._applyRangedHighlight(
          RANGE_HIGHLIGHT, range.startLine, range.startChar,
          range.endLine, range.endChar, side);
    },

    _removeActionBoxDebounced: function() {
      this.debounce('removeActionBox', this._removeActionBox, 10);
    },

    _removeActionBox: function() {
      var actionBox = this.$$('gr-selection-action-box');
      if (actionBox) {
        Polymer.dom(this.root).removeChild(actionBox);
      }
    },

    _convertOffsetToColumn: function(el, offset) {
      if (el instanceof Element && el.classList.contains('content')) {
        return offset;
      }
      while (el.previousSibling ||
          !el.parentElement.classList.contains('content')) {
        if (el.previousSibling) {
          el = el.previousSibling;
          offset += this._getLength(el);
        } else {
          el = el.parentElement;
        }
      }
      return offset;
    },

    /**
     * Traverse Element from right to left, call callback for each node.
     * Stops if callback returns true.
     *
     * @param {!Node} startNode
     * @param {function(Node):boolean} callback
     * @param {Object=} opt_flags If flags.left is true, traverse left.
     */
    _traverseContentSiblings: function(startNode, callback, opt_flags) {
      var travelLeft = opt_flags && opt_flags.left;
      var node = startNode;
      while (node) {
        if (node instanceof Element &&
            node.tagName !== 'HL' &&
            node.tagName !== 'SPAN') {
          break;
        }
        var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
        if (callback(node)) {
          break;
        }
        node = nextNode;
      }
    },

    /**
     * Get length of a node. If the node is a content node, then only give the
     * length of its .contentText child.
     *
     * @param {!Node} node
     * @return {number}
     */
    _getLength: function(node) {
      if (node instanceof Element && node.classList.contains('content')) {
        return this._getLength(node.querySelector('.contentText'));
      } else {
        // DOM API for textContent.length is broken for Unicode:
        // https://mathiasbynens.be/notes/javascript-unicode
        return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
      }
    },

    /**
     * Wraps node in hl tag with cssClass, replacing the node in DOM.
     *
     * @return {!Element} Wrapped node.
     */
    _wrapInHighlight: function(node, cssClass) {
      var hl;
      if (node.tagName === 'HL') {
        hl = node;
        hl.classList.add(cssClass);
      } else {
        hl = document.createElement('hl');
        hl.className = cssClass;
        Polymer.dom(node.parentElement).replaceChild(hl, node);
        hl.appendChild(node);
      }
      return hl;
    },

    /**
     * Node.prototype.splitText Unicode-valid alternative.
     *
     * @param {!Text} node
     * @param {number} offset
     * @return {!Text} Trailing Text Node.
     */
    _splitTextNode: function(node, offset) {
      if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
        // DOM Api for splitText() is broken for Unicode:
        // https://mathiasbynens.be/notes/javascript-unicode
        // TODO (viktard): Polyfill Array.from for IE10.
        var head = Array.from(node.textContent);
        var tail = head.splice(offset);
        var parent = node.parentElement;
        var headNode = document.createTextNode(head.join(''));
        parent.replaceChild(headNode, node);
        var tailNode = document.createTextNode(tail.join(''));
        parent.insertBefore(tailNode, headNode.nextSibling);
        return tailNode;
      } else {
        return node.splitText(offset);
      }
    },

    /**
     * Split Node at offset.
     * If Node is Element, it's cloned and the node at offset is split too.
     *
     * @param {!Node} node
     * @param {number} offset
     * @return {!Node} Trailing Node.
     */
    _splitNode: function(element, offset) {
      if (element instanceof Text) {
        return this._splitTextNode(element, offset);
      }
      var tail = element.cloneNode(false);
      element.parentElement.insertBefore(tail, element.nextSibling);
      // Skip nodes before offset.
      var node = element.firstChild;
      while (node &&
          this._getLength(node) <= offset ||
          this._getLength(node) === 0) {
        offset -= this._getLength(node);
        node = node.nextSibling;
      }
      if (this._getLength(node) > offset) {
        tail.appendChild(this._splitNode(node, offset));
      }
      while (node.nextSibling) {
        tail.appendChild(node.nextSibling);
      }
      return tail;
    },

    /**
     * Split Text Node and wrap it in hl with cssClass.
     * Wraps trailing part after split, tailing one if opt_firstPart is true.
     *
     * @param {!Node} node
     * @param {number} offset
     * @param {string} cssClass
     * @param {boolean=} opt_firstPart
     */
    _splitAndWrapInHighlight: function(node, offset, cssClass, opt_firstPart) {
      if (this._getLength(node) === offset || offset === 0) {
        return this._wrapInHighlight(node, cssClass);
      } else {
        if (opt_firstPart) {
          this._splitNode(node, offset);
          // Node points to first part of the Text, second one is sibling.
        } else {
          node = this._splitNode(node, offset);
        }
        return this._wrapInHighlight(node, cssClass);
      }
    },

    /**
     * Creates hl tag with cssClass for starting side of range highlight.
     *
     * @param {!Element} startContent Range start diff content
     *     aka div.contentText.
     * @param {!Element} endContent Range end diff content
     *     aka div.contentText.
     * @param {number} startOffset Range start within start content.
     * @param {number} endOffset Range end within end content.
     * @param {string} cssClass
     * @return {!Element} Range start node.
     */
    _normalizeStart: function(
        startContent, endContent, startOffset, endOffset, cssClass) {
      var isOneLine = startContent === endContent;
      var startNode = startContent.firstChild;
      var length = endOffset - startOffset;

      if (!startNode) {
        return startNode;
      }

      // Skip nodes before startOffset.
      var nodeLength = this._getLength(startNode);
      while (startNode && (nodeLength <= startOffset || nodeLength === 0)) {
        startOffset -= nodeLength;
        startNode = startNode.nextSibling;
        nodeLength = startNode && this._getLength(startNode);
      }
      if (!startNode) { return null; }

      // Split Text node.
      if (startNode instanceof Text) {
        startNode =
            this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
        // Edge case: single line, text node wraps the highlight.
        if (isOneLine && this._getLength(startNode) > length) {
          var extra = this._splitTextNode(startNode.firstChild, length);
          startContent.insertBefore(extra, startNode.nextSibling);
          startContent.normalize();
        }
      } else if (startNode.tagName == 'HL') {
        if (!startNode.classList.contains(cssClass)) {
          // Edge case: single line, <hl> wraps the highlight.
          // Should leave wrapping HL's content after the highlight.
          if (isOneLine && startOffset + length < this._getLength(startNode)) {
            this._splitNode(startNode, startOffset + length);
          }
          startNode =
              this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
        }
      } else {
        startNode = null;
      }
      return startNode;
    },

    /**
     * Creates hl tag with cssClass for ending side of range highlight.
     *
     * @param {!Element} startContent Range start diff content
     *     aka div.contentText.
     * @param {!Element} endContent Range end diff content
     *     aka div.contentText.
     * @param {number} startOffset Range start within start content.
     * @param {number} endOffset Range end within end content.
     * @param {string} cssClass
     * @return {!Element} Range start node.
     */
    _normalizeEnd: function(
        startContent, endContent, startOffset, endOffset, cssClass) {
      var endNode = endContent.firstChild;

      if (!endNode) {
        return endNode;
      }

      // Find the node where endOffset points at.
      var nodeLength = this._getLength(endNode);
      while (endNode && (nodeLength < endOffset || nodeLength === 0)) {
        endOffset -= nodeLength;
        endNode = endNode.nextSibling;
        nodeLength = endNode && this._getLength(endNode);
      }
      if (!endNode) { return null; }

      if (endNode instanceof Text) {
        endNode =
            this._splitAndWrapInHighlight(endNode, endOffset, cssClass, true);
      } else if (endNode.tagName == 'HL') {
        if (!endNode.classList.contains(cssClass)) {
          // Split text inside HL.
          var hl = endNode;
          endNode = this._splitAndWrapInHighlight(
              endNode, endOffset, cssClass, true);
          if (hl.textContent.length === 0) {
            hl.remove();
          }
        }
      } else {
        endNode = null;
      }
      return endNode;
    },

    /**
     * Applies highlight to first and last lines in range.
     *
     * @param {!Element} startContent Range start diff content
     *     aka div.contentText.
     * @param {!Element} endContent Range end diff content
     *     aka div.contentText.
     * @param {number} startOffset Range start within start content.
     * @param {number} endOffset Range end within end content.
     * @param {string} cssClass
     */
    _highlightSides: function(
        startContent, endContent, startOffset, endOffset, cssClass) {
      var isOneLine = startContent === endContent;
      var startNode = this._normalizeStart(
          startContent, endContent, startOffset, endOffset, cssClass);
      var endNode = this._normalizeEnd(
          startContent, endContent, startOffset, endOffset, cssClass);

      // Grow starting highlight until endNode or end of line.
      if (startNode && startNode != endNode) {
        var growStartHl = function(node) {
          if (node instanceof Text || node.tagName === 'SPAN') {
            startNode.appendChild(node);
          } else if (node.tagName === 'HL') {
            this._traverseContentSiblings(node.firstChild, growStartHl);
            node.remove();
          }
          return node == endNode;
        }.bind(this);
        this._traverseContentSiblings(startNode.nextSibling, growStartHl);
        startNode.normalize();
      }

      if (!isOneLine && endNode) {
        var growEndHl = function(node) {
          if (node instanceof Text || node.tagName === 'SPAN') {
            endNode.insertBefore(node, endNode.firstChild);
          } else if (node.tagName === 'HL') {
            this._traverseContentSiblings(node.firstChild, growEndHl);
            node.remove();
          }
        }.bind(this);
        // Prepend text up to line start to the ending highlight.
        this._traverseContentSiblings(
          endNode.previousSibling, growEndHl, {left: true});
        endNode.normalize();
      }
    },

    /**
     * @param {string} cssClass
     * @param {number} startLine Range start code line number.
     * @param {number} startCol Range start column number.
     * @param {number} endLine Range end line number.
     * @param {number} endCol Range end column number.
     * @param {string=} opt_side Side selector (right or left).
     */
    _applyRangedHighlight: function(
        cssClass, startLine, startCol, endLine, endCol, opt_side) {
      var startEl = this.diffBuilder.getContentByLine(startLine, opt_side);
      var endEl = this.diffBuilder.getContentByLine(endLine, opt_side);
      this._highlightSides(startEl, endEl, startCol, endCol, cssClass);
      if (endLine - startLine > 1) {
        // There is at least one line in between.
        var contents = this.diffBuilder.getContentsByLineRange(
            startLine + 1, endLine - 1, opt_side);
        // Wrap contents in highlight.
        contents.forEach(function(content) {
          if (content.textContent.length === 0) {
            return;
          }
          var threadEl =
                this.diffBuilder.getCommentThreadByContentEl(content);
          if (threadEl) {
            threadEl.remove();
          }
          var text = document.createTextNode(content.textContent);
          while (content.firstChild) {
            content.removeChild(content.firstChild);
          }
          content.appendChild(text);
          if (threadEl) {
            content.appendChild(threadEl);
          }
          this._wrapInHighlight(text, cssClass);
        }, this);
      }
    },

    _applyAllHighlights: function() {
      var rangedLeft =
          this.comments.left.filter(function(item) { return !!item.range; });
      var rangedRight =
          this.comments.right.filter(function(item) { return !!item.range; });
      rangedLeft.forEach(function(item) {
        var range = item.range;
        this._applyRangedHighlight(
            RANGE_HIGHLIGHT, range.start_line, range.start_character,
            range.end_line, range.end_character, 'left');
      }, this);
      rangedRight.forEach(function(item) {
        var range = item.range;
        this._applyRangedHighlight(
            RANGE_HIGHLIGHT, range.start_line, range.start_character,
            range.end_line, range.end_character, 'right');
      }, this);
    },

    _rerenderByLines: function(startLine, endLine, opt_side) {
      this.async(function() {
        this.diffBuilder.renderLineRange(startLine, endLine, opt_side);
      }, 1);
    },
  });
})();