/**
 * @license
 * 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';

  const UNRESOLVED_EXPAND_COUNT = 5;
  const NEWLINE_PATTERN = /\n/g;

  Polymer({
    is: 'gr-diff-comment-thread',

    /**
     * Fired when the thread should be discarded.
     *
     * @event thread-discard
     */

    /**
     * Fired when a comment in the thread is permanently modified.
     *
     * @event thread-changed
     */

    properties: {
      changeNum: String,
      comments: {
        type: Array,
        value() { return []; },
      },
      range: Object,
      keyEventTarget: {
        type: Object,
        value() { return document.body; },
      },
      commentSide: String,
      patchNum: String,
      path: String,
      projectName: {
        type: String,
        observer: '_projectNameChanged',
      },
      hasDraft: {
        type: Boolean,
        notify: true,
        reflectToAttribute: true,
      },
      isOnParent: {
        type: Boolean,
        value: false,
      },
      parentIndex: {
        type: Number,
        value: null,
      },
      rootId: {
        type: String,
        notify: true,
        computed: '_computeRootId(comments.*)',
      },
      /**
       * If this is true, the comment thread also needs to have the change and
       * line properties property set
       */
      showFilePath: {
        type: Boolean,
        value: false,
      },
      /** Necessary only if showFilePath is true */
      lineNum: Number,
      unresolved: {
        type: Boolean,
        notify: true,
        reflectToAttribute: true,
      },
      _showActions: Boolean,
      _lastComment: Object,
      _orderedComments: Array,
      _projectConfig: Object,
    },

    behaviors: [
      Gerrit.KeyboardShortcutBehavior,
      Gerrit.PathListBehavior,
    ],

    listeners: {
      'comment-update': '_handleCommentUpdate',
    },

    observers: [
      '_commentsChanged(comments.*)',
    ],

    keyBindings: {
      'e shift+e': '_handleEKey',
    },

    attached() {
      this._getLoggedIn().then(loggedIn => {
        this._showActions = loggedIn;
      });
      this._setInitialExpandedState();
    },

    addOrEditDraft(opt_lineNum, opt_range) {
      const lastComment = this.comments[this.comments.length - 1] || {};
      if (lastComment.__draft) {
        const commentEl = this._commentElWithDraftID(
            lastComment.id || lastComment.__draftID);
        commentEl.editing = true;

        // If the comment was collapsed, re-open it to make it clear which
        // actions are available.
        commentEl.collapsed = false;
      } else {
        const range = opt_range ? opt_range :
            lastComment ? lastComment.range : undefined;
        const unresolved = lastComment ? lastComment.unresolved : undefined;
        this.addDraft(opt_lineNum, range, unresolved);
      }
    },

    addDraft(opt_lineNum, opt_range, opt_unresolved) {
      const draft = this._newDraft(opt_lineNum, opt_range);
      draft.__editing = true;
      draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
      this.push('comments', draft);
    },

    fireRemoveSelf() {
      this.dispatchEvent(new CustomEvent('thread-discard',
          {detail: {rootId: this.rootId}, bubbles: false}));
    },

    _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
      return Gerrit.Nav.getUrlForDiffById(changeNum,
          projectName, path, patchNum,
          null, this.lineNum);
    },

    _computeDisplayPath(path) {
      const lineString = this.lineNum ? `#${this.lineNum}` : '';
      return this.computeDisplayPath(path) + lineString;
    },

    _getLoggedIn() {
      return this.$.restAPI.getLoggedIn();
    },

    _commentsChanged() {
      this._orderedComments = this._sortedComments(this.comments);
      this.updateThreadProperties();
    },

    updateThreadProperties() {
      if (this._orderedComments.length) {
        this._lastComment = this._getLastComment();
        this.unresolved = this._lastComment.unresolved;
        this.hasDraft = this._lastComment.__draft;
      }
    },

    _hideActions(_showActions, _lastComment) {
      return !_showActions || !_lastComment || !!_lastComment.__draft;
    },

    _getLastComment() {
      return this._orderedComments[this._orderedComments.length - 1] || {};
    },

    _handleEKey(e) {
      if (this.shouldSuppressKeyboardShortcut(e)) { return; }

      // Don’t preventDefault in this case because it will render the event
      // useless for other handlers (other gr-diff-comment-thread elements).
      if (e.detail.keyboardEvent.shiftKey) {
        this._expandCollapseComments(true);
      } else {
        if (this.modifierPressed(e)) { return; }
        this._expandCollapseComments(false);
      }
    },

    _expandCollapseComments(actionIsCollapse) {
      const comments =
          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
      for (const comment of comments) {
        comment.collapsed = actionIsCollapse;
      }
    },

    /**
     * Sets the initial state of the comment thread.
     * Expands the thread if one of the following is true:
     * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
     * thread is unresolved,
     * - it's a robot comment.
     */
    _setInitialExpandedState() {
      if (this._orderedComments) {
        for (let i = 0; i < this._orderedComments.length; i++) {
          const comment = this._orderedComments[i];
          const isRobotComment = !!comment.robot_id;
          // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
          const resolvedThread = !this.unresolved ||
                this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
          comment.collapsed = !isRobotComment && resolvedThread;
        }
      }
    },

    _sortedComments(comments) {
      return comments.slice().sort((c1, c2) => {
        const c1Date = c1.__date || util.parseDate(c1.updated);
        const c2Date = c2.__date || util.parseDate(c2.updated);
        const dateCompare = c1Date - c2Date;
        // Ensure drafts are at the end. There should only be one but in edge
        // cases could be more. In the unlikely event two drafts are being
        // compared, use the typical date compare.
        if (c2.__draft && !c1.__draft ) { return 0; }
        if (c1.__draft && !c2.__draft ) { return 1; }
        if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
        // If same date, fall back to sorting by id.
        return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
      });
    },

    _createReplyComment(parent, content, opt_isEditing,
        opt_unresolved) {
      this.$.reporting.recordDraftInteraction();
      const reply = this._newReply(
          this._orderedComments[this._orderedComments.length - 1].id,
          parent.line,
          content,
          opt_unresolved,
          parent.range);

      // If there is currently a comment in an editing state, add an attribute
      // so that the gr-diff-comment knows not to populate the draft text.
      for (let i = 0; i < this.comments.length; i++) {
        if (this.comments[i].__editing) {
          reply.__otherEditing = true;
          break;
        }
      }

      if (opt_isEditing) {
        reply.__editing = true;
      }

      this.push('comments', reply);

      if (!opt_isEditing) {
        // Allow the reply to render in the dom-repeat.
        this.async(() => {
          const commentEl = this._commentElWithDraftID(reply.__draftID);
          commentEl.save();
        }, 1);
      }
    },

    _isDraft(comment) {
      return !!comment.__draft;
    },

    /**
     * @param {boolean=} opt_quote
     */
    _processCommentReply(opt_quote) {
      const comment = this._lastComment;
      let quoteStr;
      if (opt_quote) {
        const msg = comment.message;
        quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
      }
      this._createReplyComment(comment, quoteStr, true, comment.unresolved);
    },

    _handleCommentReply(e) {
      this._processCommentReply();
    },

    _handleCommentQuote(e) {
      this._processCommentReply(true);
    },

    _handleCommentAck(e) {
      const comment = this._lastComment;
      this._createReplyComment(comment, 'Ack', false, false);
    },

    _handleCommentDone(e) {
      const comment = this._lastComment;
      this._createReplyComment(comment, 'Done', false, false);
    },

    _handleCommentFix(e) {
      const comment = e.detail.comment;
      const msg = comment.message;
      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
      const response = quoteStr + 'Please Fix';
      this._createReplyComment(comment, response, false, true);
    },

    _commentElWithDraftID(id) {
      const els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
      for (const el of els) {
        if (el.comment.id === id || el.comment.__draftID === id) {
          return el;
        }
      }
      return null;
    },

    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
        opt_range) {
      const d = this._newDraft(opt_lineNum);
      d.in_reply_to = inReplyTo;
      d.range = opt_range;
      if (opt_message != null) {
        d.message = opt_message;
      }
      if (opt_unresolved !== undefined) {
        d.unresolved = opt_unresolved;
      }
      return d;
    },

    /**
     * @param {number=} opt_lineNum
     * @param {!Object=} opt_range
     */
    _newDraft(opt_lineNum, opt_range) {
      const d = {
        __draft: true,
        __draftID: Math.random().toString(36),
        __date: new Date(),
        path: this.path,
        patchNum: this.patchNum,
        side: this._getSide(this.isOnParent),
        __commentSide: this.commentSide,
      };
      if (opt_lineNum) {
        d.line = opt_lineNum;
      }
      if (opt_range) {
        d.range = {
          start_line: opt_range.startLine,
          start_character: opt_range.startChar,
          end_line: opt_range.endLine,
          end_character: opt_range.endChar,
        };
      }
      if (this.parentIndex) {
        d.parent = this.parentIndex;
      }
      return d;
    },

    _getSide(isOnParent) {
      if (isOnParent) { return 'PARENT'; }
      return 'REVISION';
    },

    _computeRootId(comments) {
      // Keep the root ID even if the comment was removed, so that notification
      // to sync will know which thread to remove.
      if (!comments.base.length) { return this.rootId; }
      const rootComment = comments.base[0];
      return rootComment.id || rootComment.__draftID;
    },

    _handleCommentDiscard(e) {
      const diffCommentEl = Polymer.dom(e).rootTarget;
      const comment = diffCommentEl.comment;
      const idx = this._indexOf(comment, this.comments);
      if (idx == -1) {
        throw Error('Cannot find comment ' +
            JSON.stringify(diffCommentEl.comment));
      }
      this.splice('comments', idx, 1);
      if (this.comments.length === 0) {
        this.fireRemoveSelf();
      }
      this._handleCommentSavedOrDiscarded(e);

      // Check to see if there are any other open comments getting edited and
      // set the local storage value to its message value.
      for (const changeComment of this.comments) {
        if (changeComment.__editing) {
          const commentLocation = {
            changeNum: this.changeNum,
            patchNum: this.patchNum,
            path: changeComment.path,
            line: changeComment.line,
          };
          return this.$.storage.setDraftComment(commentLocation,
              changeComment.message);
        }
      }
    },

    _handleCommentSavedOrDiscarded(e) {
      this.dispatchEvent(new CustomEvent('thread-changed',
          {detail: {rootId: this.rootId, path: this.path},
            bubbles: false}));
    },

    _handleCommentUpdate(e) {
      const comment = e.detail.comment;
      const index = this._indexOf(comment, this.comments);
      if (index === -1) {
        // This should never happen: comment belongs to another thread.
        console.warn('Comment update for another comment thread.');
        return;
      }
      this.set(['comments', index], comment);
      // Because of the way we pass these comment objects around by-ref, in
      // combination with the fact that Polymer does dirty checking in
      // observers, the this.set() call above will not cause a thread update in
      // some situations.
      this.updateThreadProperties();
    },

    _indexOf(comment, arr) {
      for (let i = 0; i < arr.length; i++) {
        const c = arr[i];
        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
            (c.id != null && c.id == comment.id)) {
          return i;
        }
      }
      return -1;
    },

    _computeHostClass(unresolved) {
      return unresolved ? 'unresolved' : '';
    },

    /**
     * Load the project config when a project name has been provided.
     * @param {string} name The project name.
     */
    _projectNameChanged(name) {
      if (!name) { return; }
      this.$.restAPI.getProjectConfig(name).then(config => {
        this._projectConfig = config;
      });
    },
  });
})();