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

  Polymer({
    is: 'gr-diff',

    /**
     * Fired when the diff is rendered.
     *
     * @event render
     */

    properties: {
      availablePatches: Array,
      changeNum: String,
      /*
       * A single object to encompass basePatchNum and patchNum is used
       * so that both can be set at once without incremental observers
       * firing after each property changes.
       */
      patchRange: Object,
      path: String,
      prefs: {
        type: Object,
        notify: true,
      },
      projectConfig: Object,

      _prefsReady: {
        type: Object,
        readOnly: true,
        value: function() {
          return new Promise(function(resolve) {
            this._resolvePrefsReady = resolve;
          }.bind(this));
        },
      },
      _baseComments: Array,
      _comments: Array,
      _drafts: Array,
      _baseDrafts: Array,
      /**
       * Base (left side) comments and drafts grouped by line number.
       * Only used for initial rendering.
       */
      _groupedBaseComments: {
        type: Object,
        value: function() { return {}; },
      },
      /**
       * Comments and drafts (right side) grouped by line number.
       * Only used for initial rendering.
       */
      _groupedComments: {
        type: Object,
        value: function() { return {}; },
      },
      _diffResponse: Object,
      _diff: {
        type: Object,
        value: function() { return {}; },
      },
      _loggedIn: {
        type: Boolean,
        value: false,
      },
      _initialRenderComplete: {
        type: Boolean,
        value: false,
      },
      _loading: {
        type: Boolean,
        value: true,
      },
      _savedPrefs: Object,

      _diffPreferencesPromise: Object,  // Used for testing.
    },

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

    ready: function() {
      app.accountReady.then(function() {
        this._loggedIn = app.loggedIn;
      }.bind(this));
    },

    scrollToLine: function(lineNum) {
      // TODO(andybons): Should this always be the right side?
      this.$.rightDiff.scrollToLine(lineNum);
    },

    scrollToNextDiffChunk: function() {
      this.$.rightDiff.scrollToNextDiffChunk();
    },

    scrollToPreviousDiffChunk: function() {
      this.$.rightDiff.scrollToPreviousDiffChunk();
    },

    scrollToNextCommentThread: function() {
      this.$.rightDiff.scrollToNextCommentThread();
    },

    scrollToPreviousCommentThread: function() {
      this.$.rightDiff.scrollToPreviousCommentThread();
    },

    reload: function() {
      this._loading = true;
      // If a diff takes a considerable amount of time to render, the previous
      // diff can end up showing up while the DOM is constructed. Clear the
      // content on a reload to prevent this.
      this._diff = {
        leftSide: [],
        rightSide: [],
      };

      var diffLoaded = this._getDiff().then(function(diff) {
        this._diffResponse = diff;
      }.bind(this));

      var promises = [
        this._prefsReady,
        diffLoaded,
      ];

      return app.accountReady.then(function() {
        promises.push(this._getDiffComments().then(function(res) {
          this._baseComments = res.baseComments;
          this._comments = res.comments;
        }.bind(this)));

        if (!app.loggedIn) {
          this._baseDrafts = [];
          this._drafts = [];
        } else {
          promises.push(this._getDiffDrafts().then(function(res) {
            this._baseDrafts = res.baseComments;
            this._drafts = res.comments;
          }.bind(this)));
        }

        return Promise.all(promises).then(function() {
          this._render();
          this._loading = false;
        }.bind(this)).catch(function(err) {
          this._loading = false;
          alert('Oops. Something went wrong. Check the console and bug the ' +
              'PolyGerrit team for assistance.');
          throw err;
        }.bind(this));
      }.bind(this));
    },

    _getDiff: function() {
      return this.$.restAPI.getDiff(
          this.changeNum,
          this.patchRange.basePatchNum,
          this.patchRange.patchNum,
          this.path);
    },

    _getDiffComments: function() {
      return this.$.restAPI.getDiffComments(
          this.changeNum,
          this.patchRange.basePatchNum,
          this.patchRange.patchNum,
          this.path);
    },

    _getDiffDrafts: function() {
      return this.$.restAPI.getDiffDrafts(
          this.changeNum,
          this.patchRange.basePatchNum,
          this.patchRange.patchNum,
          this.path);
    },

    showDiffPreferences: function() {
      this.$.prefsOverlay.open();
    },

    _prefsChanged: function(changeRecord) {
      if (this._initialRenderComplete) {
        this._render();
      }
      this._resolvePrefsReady(changeRecord.base);
    },

    _render: function() {
      this._groupCommentsAndDrafts();
      this._processContent();

      // Allow for the initial rendering to complete before firing the event.
      this.async(function() {
        this.fire('render', null, {bubbles: false});
      }.bind(this), 1);

      this._initialRenderComplete = true;
    },

    _handlePrefsTap: function(e) {
      e.preventDefault();

      // TODO(andybons): This is not supported in IE. Implement a polyfill.
      // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
      // an object as a value, it must be marked enumerable.
      this._savedPrefs = Object.assign({}, this.prefs);
      this.$.prefsOverlay.open();
    },

    _handlePrefsSave: function(e) {
      e.stopPropagation();
      var el = Polymer.dom(e).rootTarget;
      el.disabled = true;
      app.accountReady.then(function() {
        if (!this._loggedIn) {
          el.disabled = false;
          this.$.prefsOverlay.close();
          return;
        }
        this._saveDiffPreferences().then(function() {
          this.$.prefsOverlay.close();
          el.disabled = false;
        }.bind(this)).catch(function(err) {
          el.disabled = false;
          alert('Oops. Something went wrong. Check the console and bug the ' +
                'PolyGerrit team for assistance.');
          throw err;
        });
      }.bind(this));
    },

    _saveDiffPreferences: function() {
      var xhr = document.createElement('gr-request');
      this._diffPreferencesPromise = xhr.send({
        method: 'PUT',
        url: '/accounts/self/preferences.diff',
        body: this.prefs,
      });
      return this._diffPreferencesPromise;
    },

    _handlePrefsCancel: function(e) {
      e.stopPropagation();
      this.prefs = this._savedPrefs;
      this.$.prefsOverlay.close();
    },

    _handleExpandContext: function(e) {
      var ctx = e.detail.context;
      var contextControlIndex = -1;
      for (var i = ctx.start; i <= ctx.end; i++) {
        this._diff.leftSide[i].hidden = false;
        this._diff.rightSide[i].hidden = false;
        if (this._diff.leftSide[i].type == 'CONTEXT_CONTROL' &&
            this._diff.rightSide[i].type == 'CONTEXT_CONTROL') {
          contextControlIndex = i;
        }
      }
      this._diff.leftSide[contextControlIndex].hidden = true;
      this._diff.rightSide[contextControlIndex].hidden = true;

      this.$.leftDiff.hideElementsWithIndex(contextControlIndex);
      this.$.rightDiff.hideElementsWithIndex(contextControlIndex);

      this.$.leftDiff.renderLineIndexRange(ctx.start, ctx.end);
      this.$.rightDiff.renderLineIndexRange(ctx.start, ctx.end);
    },

    _handleThreadHeightChange: function(e) {
      var index = e.detail.index;
      var diffEl = Polymer.dom(e).rootTarget;
      var otherSide = diffEl == this.$.leftDiff ?
          this.$.rightDiff : this.$.leftDiff;

      var threadHeight = e.detail.height;
      var otherSideHeight;
      if (otherSide.content[index].type == 'COMMENT_THREAD') {
        otherSideHeight = otherSide.getRowNaturalHeight(index);
      } else {
        otherSideHeight = otherSide.getRowHeight(index);
      }
      var maxHeight = Math.max(threadHeight, otherSideHeight);
      this.$.leftDiff.setRowHeight(index, maxHeight);
      this.$.rightDiff.setRowHeight(index, maxHeight);
    },

    _handleAddDraft: function(e) {
      var insertIndex = e.detail.index + 1;
      var diffEl = Polymer.dom(e).rootTarget;
      var content = diffEl.content;
      if (content[insertIndex] &&
          content[insertIndex].type == 'COMMENT_THREAD') {
        // A thread is already here. Do nothing.
        return;
      }
      var comment = {
        type: 'COMMENT_THREAD',
        comments: [{
          __draft: true,
          __draftID: Math.random().toString(36),
          line: e.detail.line,
          path: this.path,
        }]
      };
      if (diffEl == this.$.leftDiff &&
          this.patchRange.basePatchNum == 'PARENT') {
        comment.comments[0].side = 'PARENT';
        comment.patchNum = this.patchRange.patchNum;
      }

      if (content[insertIndex] &&
          content[insertIndex].type == 'FILLER') {
        content[insertIndex] = comment;
        diffEl.rowUpdated(insertIndex);
      } else {
        content.splice(insertIndex, 0, comment);
        diffEl.rowInserted(insertIndex);
      }

      var otherSide = diffEl == this.$.leftDiff ?
          this.$.rightDiff : this.$.leftDiff;
      if (otherSide.content[insertIndex] == null ||
          otherSide.content[insertIndex].type != 'COMMENT_THREAD') {
        otherSide.content.splice(insertIndex, 0, {
          type: 'FILLER',
        });
        otherSide.rowInserted(insertIndex);
      }
    },

    _handleRemoveThread: function(e) {
      var diffEl = Polymer.dom(e).rootTarget;
      var otherSide = diffEl == this.$.leftDiff ?
          this.$.rightDiff : this.$.leftDiff;
      var index = e.detail.index;

      if (otherSide.content[index].type == 'FILLER') {
        otherSide.content.splice(index, 1);
        otherSide.rowRemoved(index);
        diffEl.content.splice(index, 1);
        diffEl.rowRemoved(index);
      } else if (otherSide.content[index].type == 'COMMENT_THREAD') {
        diffEl.content[index] = {type: 'FILLER'};
        diffEl.rowUpdated(index);
        var height = otherSide.setRowNaturalHeight(index);
        diffEl.setRowHeight(index, height);
      } else {
        throw Error('A thread cannot be opposite anything but filler or ' +
            'another thread');
      }
    },

    _processContent: function() {
      var leftSide = [];
      var rightSide = [];
      var initialLineNum = 0 + (this._diffResponse.content.skip || 0);
      var ctx = {
        hidingLines: false,
        lastNumLinesHidden: 0,
        left: {
          lineNum: initialLineNum,
        },
        right: {
          lineNum: initialLineNum,
        }
      };
      var content = this._breakUpCommonChunksWithComments(ctx,
          this._diffResponse.content);
      var context = this.prefs.context;
      if (context == -1) {
        // Show the entire file.
        context = Infinity;
      }
      for (var i = 0; i < content.length; i++) {
        if (i == 0) {
          ctx.skipRange = [0, context];
        } else if (i == content.length - 1) {
          ctx.skipRange = [context, 0];
        } else {
          ctx.skipRange = [context, context];
        }
        ctx.diffChunkIndex = i;
        this._addDiffChunk(ctx, content[i], leftSide, rightSide);
      }

      this._diff = {
        leftSide: leftSide,
        rightSide: rightSide,
      };
    },

    // In order to show comments out of the bounds of the selected context,
    // treat them as diffs within the model so that the content (and context
    // surrounding it) renders correctly.
    _breakUpCommonChunksWithComments: function(ctx, content) {
      var result = [];
      var leftLineNum = ctx.left.lineNum;
      var rightLineNum = ctx.right.lineNum;
      for (var i = 0; i < content.length; i++) {
        if (!content[i].ab) {
          result.push(content[i]);
          if (content[i].a) {
            leftLineNum += content[i].a.length;
          }
          if (content[i].b) {
            rightLineNum += content[i].b.length;
          }
          continue;
        }
        var chunk = content[i].ab;
        var currentChunk = {ab: []};
        for (var j = 0; j < chunk.length; j++) {
          leftLineNum++;
          rightLineNum++;
          if (this._groupedBaseComments[leftLineNum] == null &&
              this._groupedComments[rightLineNum] == null) {
            currentChunk.ab.push(chunk[j]);
          } else {
            if (currentChunk.ab && currentChunk.ab.length > 0) {
              result.push(currentChunk);
              currentChunk = {ab: []};
            }
            // Append an annotation to indicate that this line should not be
            // highlighted even though it's implied with both `a` and `b`
            // defined. This is needed since there may be two lines that
            // should be highlighted but are equal (blank lines, for example).
            result.push({
              __noHighlight: true,
              a: [chunk[j]],
              b: [chunk[j]],
            });
          }
        }
        if (currentChunk.ab != null && currentChunk.ab.length > 0) {
          result.push(currentChunk);
        }
      }
      return result;
    },

    _groupCommentsAndDrafts: function() {
      this._baseDrafts.forEach(function(d) { d.__draft = true; });
      this._drafts.forEach(function(d) { d.__draft = true; });
      var allLeft = this._baseComments.concat(this._baseDrafts);
      var allRight = this._comments.concat(this._drafts);

      var leftByLine = {};
      var rightByLine = {};
      var mapFunc = function(byLine) {
        return function(c) {
          // File comments/drafts are grouped with line 1 for now.
          var line = c.line || 1;
          if (byLine[line] == null) {
            byLine[line] = [];
          }
          byLine[line].push(c);
        };
      };
      allLeft.forEach(mapFunc(leftByLine));
      allRight.forEach(mapFunc(rightByLine));

      this._groupedBaseComments = leftByLine;
      this._groupedComments = rightByLine;
    },

    _addContextControl: function(ctx, leftSide, rightSide) {
      var numLinesHidden = ctx.lastNumLinesHidden;
      var leftStart = leftSide.length - numLinesHidden;
      var leftEnd = leftSide.length;
      var rightStart = rightSide.length - numLinesHidden;
      var rightEnd = rightSide.length;
      if (leftStart != rightStart || leftEnd != rightEnd) {
        throw Error(
            'Left and right ranges for context control should be equal:' +
            'Left: [' + leftStart + ', ' + leftEnd + '] ' +
            'Right: [' + rightStart + ', ' + rightEnd + ']');
      }
      var obj = {
        type: 'CONTEXT_CONTROL',
        numLines: numLinesHidden,
        start: leftStart,
        end: leftEnd,
      };
      // NOTE: Be careful, here. This object is meant to be immutable. If the
      // object is altered within one side's array it will reflect the
      // alterations in another.
      leftSide.push(obj);
      rightSide.push(obj);
    },

    _addCommonDiffChunk: function(ctx, chunk, leftSide, rightSide) {
      for (var i = 0; i < chunk.ab.length; i++) {
        var numLines = Math.ceil(
            this._visibleLineLength(chunk.ab[i]) / this.prefs.line_length);
        var hidden = i >= ctx.skipRange[0] &&
            i < chunk.ab.length - ctx.skipRange[1];
        if (ctx.hidingLines && hidden == false) {
          // No longer hiding lines. Add a context control.
          this._addContextControl(ctx, leftSide, rightSide);
          ctx.lastNumLinesHidden = 0;
        }
        ctx.hidingLines = hidden;
        if (hidden) {
          ctx.lastNumLinesHidden++;
        }

        // Blank lines within a diff content array indicate a newline.
        leftSide.push({
          type: 'CODE',
          hidden: hidden,
          content: chunk.ab[i] || '\n',
          numLines: numLines,
          lineNum: ++ctx.left.lineNum,
        });
        rightSide.push({
          type: 'CODE',
          hidden: hidden,
          content: chunk.ab[i] || '\n',
          numLines: numLines,
          lineNum: ++ctx.right.lineNum,
        });

        this._addCommentsIfPresent(ctx, leftSide, rightSide);
      }
      if (ctx.lastNumLinesHidden > 0) {
        this._addContextControl(ctx, leftSide, rightSide);
      }
    },

    _addDiffChunk: function(ctx, chunk, leftSide, rightSide) {
      if (chunk.ab) {
        this._addCommonDiffChunk(ctx, chunk, leftSide, rightSide);
        return;
      }

      var leftHighlights = [];
      if (chunk.edit_a) {
        leftHighlights =
            this._normalizeIntralineHighlights(chunk.a, chunk.edit_a);
      }
      var rightHighlights = [];
      if (chunk.edit_b) {
        rightHighlights =
            this._normalizeIntralineHighlights(chunk.b, chunk.edit_b);
      }

      var aLen = (chunk.a && chunk.a.length) || 0;
      var bLen = (chunk.b && chunk.b.length) || 0;
      var maxLen = Math.max(aLen, bLen);
      for (var i = 0; i < maxLen; i++) {
        var hasLeftContent = chunk.a && i < chunk.a.length;
        var hasRightContent = chunk.b && i < chunk.b.length;
        var leftContent = hasLeftContent ? chunk.a[i] : '';
        var rightContent = hasRightContent ? chunk.b[i] : '';
        var highlight = !chunk.__noHighlight;
        var maxNumLines = this._maxLinesSpanned(leftContent, rightContent);
        if (hasLeftContent) {
          leftSide.push({
            type: 'CODE',
            content: leftContent || '\n',
            numLines: maxNumLines,
            lineNum: ++ctx.left.lineNum,
            highlight: highlight,
            intraline: highlight && leftHighlights.filter(function(hl) {
              return hl.contentIndex == i;
            }),
          });
        } else {
          leftSide.push({
            type: 'FILLER',
            numLines: maxNumLines,
          });
        }
        if (hasRightContent) {
          rightSide.push({
            type: 'CODE',
            content: rightContent || '\n',
            numLines: maxNumLines,
            lineNum: ++ctx.right.lineNum,
            highlight: highlight,
            intraline: highlight && rightHighlights.filter(function(hl) {
              return hl.contentIndex == i;
            }),
          });
        } else {
          rightSide.push({
            type: 'FILLER',
            numLines: maxNumLines,
          });
        }
        this._addCommentsIfPresent(ctx, leftSide, rightSide);
      }
    },

    _addCommentsIfPresent: function(ctx, leftSide, rightSide) {
      var leftComments = this._groupedBaseComments[ctx.left.lineNum];
      var rightComments = this._groupedComments[ctx.right.lineNum];
      if (leftComments) {
        var thread = {
          type: 'COMMENT_THREAD',
          comments: leftComments,
        };
        if (this.patchRange.basePatchNum == 'PARENT') {
          thread.patchNum = this.patchRange.patchNum;
        }
        leftSide.push(thread);
      }
      if (rightComments) {
        rightSide.push({
          type: 'COMMENT_THREAD',
          comments: rightComments,
        });
      }
      if (leftComments && !rightComments) {
        rightSide.push({type: 'FILLER'});
      } else if (!leftComments && rightComments) {
        leftSide.push({type: 'FILLER'});
      }
      this._groupedBaseComments[ctx.left.lineNum] = null;
      this._groupedComments[ctx.right.lineNum] = null;
    },

    // The `highlights` array consists of a list of <skip length, mark length>
    // pairs, where the skip length is the number of characters between the
    // end of the previous edit and the start of this edit, and the mark
    // length is the number of edited characters following the skip. The start
    // of the edits is from the beginning of the related diff content lines.
    //
    // Note that the implied newline character at the end of each line is
    // included in the length calculation, and thus it is possible for the
    // edits to span newlines.
    //
    // A line highlight object consists of three fields:
    // - contentIndex: The index of the diffChunk `content` field (the line
    //   being referred to).
    // - startIndex: Where the highlight should begin.
    // - endIndex: (optional) Where the highlight should end. If omitted, the
    //   highlight is meant to be a continuation onto the next line.
    _normalizeIntralineHighlights: function(content, highlights) {
      var contentIndex = 0;
      var idx = 0;
      var normalized = [];
      for (var i = 0; i < highlights.length; i++) {
        var line = content[contentIndex] + '\n';
        var hl = highlights[i];
        var j = 0;
        while (j < hl[0]) {
          if (idx == line.length) {
            idx = 0;
            line = content[++contentIndex] + '\n';
            continue;
          }
          idx++;
          j++;
        }
        var lineHighlight = {
          contentIndex: contentIndex,
          startIndex: idx,
        };

        j = 0;
        while (line && j < hl[1]) {
          if (idx == line.length) {
            idx = 0;
            line = content[++contentIndex] + '\n';
            normalized.push(lineHighlight);
            lineHighlight = {
              contentIndex: contentIndex,
              startIndex: idx,
            };
            continue;
          }
          idx++;
          j++;
        }
        lineHighlight.endIndex = idx;
        normalized.push(lineHighlight);
      }
      return normalized;
    },

    _visibleLineLength: function(contents) {
      // http://jsperf.com/performance-of-match-vs-split
      var numTabs = contents.split('\t').length - 1;
      return contents.length - numTabs + (this.prefs.tab_size * numTabs);
    },

    _maxLinesSpanned: function(left, right) {
      return Math.max(
          Math.ceil(this._visibleLineLength(left) / this.prefs.line_length),
          Math.ceil(this._visibleLineLength(right) / this.prefs.line_length));
    },
  });
})();