// 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';

  var CharCode = {
    LESS_THAN: '<'.charCodeAt(0),
    GREATER_THAN: '>'.charCodeAt(0),
    AMPERSAND: '&'.charCodeAt(0),
    SEMICOLON: ';'.charCodeAt(0),
  };

  var TAB_REGEX = /\t/g;

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

    /**
     * Fired when an expand context control is clicked.
     *
     * @event expand-context
     */

    /**
     * Fired when a thread's height is changed.
     *
     * @event thread-height-change
     */

    /**
     * Fired when a draft should be added.
     *
     * @event add-draft
     */

    /**
     * Fired when a thread is removed.
     *
     * @event remove-thread
     */

    properties: {
      canComment: {
        type: Boolean,
        value: false,
      },
      content: {
        type: Array,
        notify: true,
        observer: '_contentChanged',
      },
      prefs: {
        type: Object,
        value: function() { return {}; },
      },
      changeNum: String,
      patchNum: String,
      path: String,
      projectConfig: {
        type: Object,
        observer: '_projectConfigChanged',
      },

      _lineFeedHTML: {
        type: String,
        value: '<span class="style-scope gr-diff-side br"></span>',
        readOnly: true,
      },
      _highlightStartTag: {
        type: String,
        value: '<hl class="style-scope gr-diff-side">',
        readOnly: true,
      },
      _highlightEndTag: {
        type: String,
        value: '</hl>',
        readOnly: true,
      },
      _diffChunkLineNums: {
        type: Array,
        value: function() { return []; },
      },
      _commentThreadLineNums: {
        type: Array,
        value: function() { return []; },
      },
      _focusedLineNum: {
        type: Number,
        value: 1,
      },
    },

    listeners: {
      'tap': '_tapHandler',
    },

    observers: [
      '_prefsChanged(prefs.*)',
    ],

    rowInserted: function(index) {
      this.renderLineIndexRange(index, index);
      this._updateDOMIndices();
      this._updateJumpIndices();
    },

    rowRemoved: function(index) {
      var removedEls = Polymer.dom(this.root).querySelectorAll(
          '[data-index="' + index + '"]');
      for (var i = 0; i < removedEls.length; i++) {
        removedEls[i].parentNode.removeChild(removedEls[i]);
      }
      this._updateDOMIndices();
      this._updateJumpIndices();
    },

    rowUpdated: function(index) {
      var removedEls = Polymer.dom(this.root).querySelectorAll(
          '[data-index="' + index + '"]');
      for (var i = 0; i < removedEls.length; i++) {
        removedEls[i].parentNode.removeChild(removedEls[i]);
      }
      this.renderLineIndexRange(index, index);
    },

    scrollToLine: function(lineNum) {
      if (isNaN(lineNum) || lineNum < 1) { return; }

      var el = this.$$('.numbers .lineNum[data-line-num="' + lineNum + '"]');
      if (!el) { return; }

      // Calculate where the line is relative to the window.
      var top = el.offsetTop;
      for (var offsetParent = el.offsetParent;
           offsetParent;
           offsetParent = offsetParent.offsetParent) {
        top += offsetParent.offsetTop;
      }

      // Scroll the element to the middle of the window. Dividing by a third
      // instead of half the inner height feels a bit better otherwise the
      // element appears to be below the center of the window even when it
      // isn't.
      window.scrollTo(0, top - (window.innerHeight / 3) - el.offsetHeight);
    },

    scrollToNextDiffChunk: function() {
      this._scrollToNextChunkOrThread(this._diffChunkLineNums);
    },

    scrollToPreviousDiffChunk: function() {
      this._scrollToPreviousChunkOrThread(this._diffChunkLineNums);
    },

    scrollToNextCommentThread: function() {
      this._scrollToNextChunkOrThread(this._commentThreadLineNums);
    },

    scrollToPreviousCommentThread: function() {
      this._scrollToPreviousChunkOrThread(this._commentThreadLineNums);
    },

    renderLineIndexRange: function(startIndex, endIndex) {
      this._render(this.content, startIndex, endIndex);
    },

    hideElementsWithIndex: function(index) {
      var els = Polymer.dom(this.root).querySelectorAll(
          '[data-index="' + index + '"]');
      for (var i = 0; i < els.length; i++) {
        els[i].setAttribute('hidden', true);
      }
    },

    getRowHeight: function(index) {
      var row = this.content[index];
      // Filler elements should not be taken into account when determining
      // height calculations.
      if (row.type == 'FILLER') {
        return 0;
      }
      if (row.height != null) {
        return row.height;
      }

      var selector = '[data-index="' + index + '"]';
      var els = Polymer.dom(this.root).querySelectorAll(selector);
      if (els.length != 2) {
        throw Error('Rows should only consist of two elements');
      }
      return Math.max(els[0].offsetHeight, els[1].offsetHeight);
    },

    getRowNaturalHeight: function(index) {
      var contentEl = this.$$('.content [data-index="' + index + '"]');
      return contentEl.naturalHeight || contentEl.offsetHeight;
    },

    setRowNaturalHeight: function(index) {
      var lineEl = this.$$('.numbers [data-index="' + index + '"]');
      var contentEl = this.$$('.content [data-index="' + index + '"]');
      contentEl.style.height = null;
      var height = contentEl.offsetHeight;
      lineEl.style.height = height + 'px';
      this.content[index].height = height;
      return height;
    },

    setRowHeight: function(index, height) {
      var selector = '[data-index="' + index + '"]';
      var els = Polymer.dom(this.root).querySelectorAll(selector);
      for (var i = 0; i < els.length; i++) {
        els[i].style.height = height + 'px';
      }
      this.content[index].height = height;
    },

    _scrollToNextChunkOrThread: function(lineNums) {
      for (var i = 0; i < lineNums.length; i++) {
        if (lineNums[i] > this._focusedLineNum) {
          this._focusedLineNum = lineNums[i];
          this.scrollToLine(this._focusedLineNum);
          return;
        }
      }
    },

    _scrollToPreviousChunkOrThread: function(lineNums) {
      for (var i = lineNums.length - 1; i >= 0; i--) {
        if (this._focusedLineNum > lineNums[i]) {
          this._focusedLineNum = lineNums[i];
          this.scrollToLine(this._focusedLineNum);
          return;
        }
      }
    },

    _updateJumpIndices: function() {
      this._commentThreadLineNums = [];
      this._diffChunkLineNums = [];
      var inHighlight = false;
      for (var i = 0; i < this.content.length; i++) {
        switch (this.content[i].type) {
          case 'COMMENT_THREAD':
            this._commentThreadLineNums.push(
                this.content[i].comments[0].line);
            break;
          case 'CODE':
            // Only grab the first line of the highlighted chunk.
            if (!inHighlight && this.content[i].highlight) {
              this._diffChunkLineNums.push(this.content[i].lineNum);
              inHighlight = true;
            } else if (!this.content[i].highlight) {
              inHighlight = false;
            }
            break;
        }
      }
    },

    _updateDOMIndices: function() {
      // There is no way to select elements with a data-index greater than a
      // given value. For now, just update all DOM elements.
      var lineEls = Polymer.dom(this.root).querySelectorAll(
          '.numbers [data-index]');
      var contentEls = Polymer.dom(this.root).querySelectorAll(
          '.content [data-index]');
      if (lineEls.length != contentEls.length) {
        throw Error(
            'There must be the same number of line and content elements');
      }
      var index = 0;
      for (var i = 0; i < this.content.length; i++) {
        if (this.content[i].hidden) { continue; }

        lineEls[index].setAttribute('data-index', i);
        contentEls[index].setAttribute('data-index', i);
        index++;
      }
    },

    _prefsChanged: function(changeRecord) {
      var prefs = changeRecord.base;
      this.$.content.style.width = prefs.line_length + 'ch';
    },

    _projectConfigChanged: function(projectConfig) {
      var threadEls =
          Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
      for (var i = 0; i < threadEls.length; i++) {
        threadEls[i].projectConfig = projectConfig;
      }
    },

    _contentChanged: function(diff) {
      this._clearChildren(this.$.numbers);
      this._clearChildren(this.$.content);
      this._render(diff, 0, diff.length - 1);
      this._updateJumpIndices();
    },

    _computeContainerClass: function(canComment) {
      return 'container' + (canComment ? ' canComment' : '');
    },

    _tapHandler: function(e) {
      var lineEl = Polymer.dom(e).rootTarget;
      if (!this.canComment || !lineEl.classList.contains('lineNum')) {
        return;
      }

      e.preventDefault();
      var index = parseInt(lineEl.getAttribute('data-index'), 10);
      var line = parseInt(lineEl.getAttribute('data-line-num'), 10);
      this.fire('add-draft', {
        index: index,
        line: line
      }, {bubbles: false});
    },

    _clearChildren: function(el) {
      while (el.firstChild) {
        el.removeChild(el.firstChild);
      }
    },

    _handleContextControlClick: function(context, e) {
      e.preventDefault();
      this.fire('expand-context', {context: context}, {bubbles: false});
    },

    _render: function(diff, startIndex, endIndex) {
      var beforeLineEl;
      var beforeContentEl;
      if (endIndex != diff.length - 1) {
        beforeLineEl = this.$$('.numbers [data-index="' + endIndex + '"]');
        beforeContentEl = this.$$('.content [data-index="' + endIndex + '"]');
        if (!beforeLineEl && !beforeContentEl) {
          // `endIndex` may be present within the model, but not in the DOM.
          // Insert it before its successive element.
          beforeLineEl = this.$$(
              '.numbers [data-index="' + (endIndex + 1) + '"]');
          beforeContentEl = this.$$(
              '.content [data-index="' + (endIndex + 1) + '"]');
        }
      }

      for (var i = startIndex; i <= endIndex; i++) {
        if (diff[i].hidden) { continue; }

        switch (diff[i].type) {
          case 'CODE':
            this._renderCode(diff[i], i, beforeLineEl, beforeContentEl);
            break;
          case 'FILLER':
            this._renderFiller(diff[i], i, beforeLineEl, beforeContentEl);
            break;
          case 'CONTEXT_CONTROL':
            this._renderContextControl(diff[i], i, beforeLineEl,
                beforeContentEl);
            break;
          case 'COMMENT_THREAD':
            this._renderCommentThread(diff[i], i, beforeLineEl,
                beforeContentEl);
            break;
        }
      }
    },

    _handleCommentThreadHeightChange: function(e) {
      var threadEl = Polymer.dom(e).rootTarget;
      var index = parseInt(threadEl.getAttribute('data-index'), 10);
      this.content[index].height = e.detail.height;
      var lineEl = this.$$('.numbers [data-index="' + index + '"]');
      lineEl.style.height = e.detail.height + 'px';
      this.fire('thread-height-change', {
        index: index,
        height: e.detail.height,
      }, {bubbles: false});
    },

    _handleCommentThreadDiscard: function(e) {
      var threadEl = Polymer.dom(e).rootTarget;
      var index = parseInt(threadEl.getAttribute('data-index'), 10);
      this.fire('remove-thread', {index: index}, {bubbles: false});
    },

    _renderCommentThread: function(thread, index, beforeLineEl,
        beforeContentEl) {
      var lineEl = this._createElement('div', 'commentThread');
      lineEl.classList.add('filler');
      lineEl.setAttribute('data-index', index);
      var threadEl = document.createElement('gr-diff-comment-thread');
      threadEl.addEventListener('height-change',
          this._handleCommentThreadHeightChange.bind(this));
      threadEl.addEventListener('thread-discard',
          this._handleCommentThreadDiscard.bind(this));
      threadEl.setAttribute('data-index', index);
      threadEl.changeNum = this.changeNum;
      threadEl.patchNum = thread.patchNum || this.patchNum;
      threadEl.path = this.path;
      threadEl.comments = thread.comments;
      threadEl.projectConfig = this.projectConfig;

      this.$.numbers.insertBefore(lineEl, beforeLineEl);
      this.$.content.insertBefore(threadEl, beforeContentEl);
    },

    _renderContextControl: function(control, index, beforeLineEl,
        beforeContentEl) {
      var lineEl = this._createElement('div', 'contextControl');
      lineEl.setAttribute('data-index', index);
      lineEl.textContent = '@@';
      var contentEl = this._createElement('div', 'contextControl');
      contentEl.setAttribute('data-index', index);
      var a = this._createElement('a');
      a.href = '#';
      a.textContent = 'Show ' + control.numLines + ' common ' +
          (control.numLines == 1 ? 'line' : 'lines') + '...';
      a.addEventListener('click',
          this._handleContextControlClick.bind(this, control));
      contentEl.appendChild(a);

      this.$.numbers.insertBefore(lineEl, beforeLineEl);
      this.$.content.insertBefore(contentEl, beforeContentEl);
    },

    _renderFiller: function(filler, index, beforeLineEl, beforeContentEl) {
      var lineFillerEl = this._createElement('div', 'filler');
      lineFillerEl.setAttribute('data-index', index);
      var fillerEl = this._createElement('div', 'filler');
      fillerEl.setAttribute('data-index', index);
      var numLines = filler.numLines || 1;

      lineFillerEl.textContent = '\n'.repeat(numLines);
      for (var i = 0; i < numLines; i++) {
        var newlineEl = this._createElement('span', 'br');
        fillerEl.appendChild(newlineEl);
      }

      this.$.numbers.insertBefore(lineFillerEl, beforeLineEl);
      this.$.content.insertBefore(fillerEl, beforeContentEl);
    },

    _renderCode: function(code, index, beforeLineEl, beforeContentEl) {
      var lineNumEl = this._createElement('div', 'lineNum');
      lineNumEl.setAttribute('data-line-num', code.lineNum);
      lineNumEl.setAttribute('data-index', index);
      var numLines = code.numLines || 1;
      lineNumEl.textContent = code.lineNum + '\n'.repeat(numLines);

      var contentEl = this._createElement('div', 'code');
      contentEl.setAttribute('data-line-num', code.lineNum);
      contentEl.setAttribute('data-index', index);

      if (code.highlight) {
        contentEl.classList.add(code.intraline.length > 0 ?
            'lightHighlight' : 'darkHighlight');
      }

      var html = util.escapeHTML(code.content);
      if (code.highlight && code.intraline.length > 0) {
        html = this._addIntralineHighlights(code.content, html,
            code.intraline);
      }
      if (numLines > 1) {
        html = this._addNewLines(code.content, html, numLines);
      }
      html = this._addTabWrappers(code.content, html);

      // If the html is equivalent to the text then it didn't get highlighted
      // or escaped. Use textContent which is faster than innerHTML.
      if (code.content == html) {
        contentEl.textContent = code.content;
      } else {
        contentEl.innerHTML = html;
      }

      this.$.numbers.insertBefore(lineNumEl, beforeLineEl);
      this.$.content.insertBefore(contentEl, beforeContentEl);
    },

    // Advance `index` by the appropriate number of characters that would
    // represent one source code character and return that index. For
    // example, for source code '<span>' the escaped html string is
    // '&lt;span&gt;'. Advancing from index 0 on the prior html string would
    // return 4, since &lt; maps to one source code character ('<').
    _advanceChar: function(html, index) {
      // Any tags don't count as characters
      while (index < html.length &&
             html.charCodeAt(index) == CharCode.LESS_THAN) {
        while (index < html.length &&
               html.charCodeAt(index) != CharCode.GREATER_THAN) {
          index++;
        }
        index++;  // skip the ">" itself
      }
      // An HTML entity (e.g., &lt;) counts as one character.
      if (index < html.length &&
          html.charCodeAt(index) == CharCode.AMPERSAND) {
        while (index < html.length &&
               html.charCodeAt(index) != CharCode.SEMICOLON) {
          index++;
        }
      }
      return index + 1;
    },

    _addIntralineHighlights: function(content, html, highlights) {
      var startTag = this._highlightStartTag;
      var endTag = this._highlightEndTag;

      for (var i = 0; i < highlights.length; i++) {
        var hl = highlights[i];

        var htmlStartIndex = 0;
        for (var j = 0; j < hl.startIndex; j++) {
          htmlStartIndex = this._advanceChar(html, htmlStartIndex);
        }

        var htmlEndIndex = 0;
        if (hl.endIndex != null) {
          for (var j = 0; j < hl.endIndex; j++) {
            htmlEndIndex = this._advanceChar(html, htmlEndIndex);
          }
        } else {
          // If endIndex isn't present, continue to the end of the line.
          htmlEndIndex = html.length;
        }
        // The start and end indices could be the same if a highlight is meant
        // to start at the end of a line and continue onto the next one.
        // Ignore it.
        if (htmlStartIndex != htmlEndIndex) {
          html = html.slice(0, htmlStartIndex) + startTag +
                html.slice(htmlStartIndex, htmlEndIndex) + endTag +
                html.slice(htmlEndIndex);
        }
      }
      return html;
    },

    _addNewLines: function(content, html, numLines) {
      var htmlIndex = 0;
      var indices = [];
      var numChars = 0;
      for (var i = 0; i < content.length; i++) {
        if (numChars > 0 && numChars % this.prefs.line_length == 0) {
          indices.push(htmlIndex);
        }
        htmlIndex = this._advanceChar(html, htmlIndex);
        if (content[i] == '\t') {
          numChars += this.prefs.tab_size;
        } else {
          numChars++;
        }
      }
      var result = html;
      var linesLeft = numLines;
      // Since the result string is being altered in place, start from the end
      // of the string so that the insertion indices are not affected as the
      // result string changes.
      for (var i = indices.length - 1; i >= 0; i--) {
        result = result.slice(0, indices[i]) + this._lineFeedHTML +
            result.slice(indices[i]);
        linesLeft--;
      }
      // numLines is the total number of lines this code block should take up.
      // Fill in the remaining ones.
      for (var i = 0; i < linesLeft; i++) {
        result += this._lineFeedHTML;
      }
      return result;
    },

    _addTabWrappers: function(content, html) {
      // TODO(andybons): CSS tab-size is not supported in IE.
      // Force this to be a number to prevent arbitrary injection.
      var tabSize = +this.prefs.tab_size;
      var htmlStr = '<span class="style-scope gr-diff-side tab ' +
          (this.prefs.show_tabs ? 'withIndicator" ' : '" ') +
          'style="tab-size:' + tabSize + ';' +
          '-moz-tab-size:' + tabSize + ';">\t</span>';
      return html.replace(TAB_REGEX, htmlStr);
    },

    _createElement: function(tagName, className) {
      var el = document.createElement(tagName);
      // When Shady DOM is being used, these classes are added to account for
      // Polymer's polyfill behavior. In order to guarantee sufficient
      // specificity within the CSS rules, these are added to every element.
      // Since the Polymer DOM utility functions (which would do this
      // automatically) are not being used for performance reasons, this is
      // done manually.
      el.classList.add('style-scope', 'gr-diff-side');
      if (!!className) {
        el.classList.add(className);
      }
      return el;
    },
  });
})();