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

  // Maximum length for patch set descriptions.
  const PATCH_DESC_MAX_LENGTH = 500;
  const WARN_SHOW_ALL_THRESHOLD = 1000;
  const LOADING_DEBOUNCE_INTERVAL = 100;

  const SIZE_BAR_MAX_WIDTH = 61;
  const SIZE_BAR_GAP_WIDTH = 1;
  const SIZE_BAR_MIN_WIDTH = 1.5;

  const RENDER_TIMING_LABEL = 'FileListRenderTime';
  const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
  const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
  const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';

  const FileStatus = {
    A: 'Added',
    C: 'Copied',
    D: 'Deleted',
    M: 'Modified',
    R: 'Renamed',
    W: 'Rewritten',
    U: 'Unchanged',
  };

  const Defs = {};

  /**
   * Object containing layout values to be used in rendering size-bars.
   * `max{Inserted,Deleted}` represent the largest values of the
   * `lines_inserted` and `lines_deleted` fields of the files respectively. The
   * `max{Addition,Deletion}Width` represent the width of the graphic allocated
   * to the insertion or deletion side respectively. Finally, the
   * `deletionOffset` value represents the x-position for the deletion bar.
   *
   * @typedef {{
   *    maxInserted: number,
   *    maxDeleted: number,
   *    maxAdditionWidth: number,
   *    maxDeletionWidth: number,
   *    deletionOffset: number,
   * }}
   */
  Defs.LayoutStats;

  Polymer({
    is: 'gr-file-list',

    /**
     * Fired when a draft refresh should get triggered
     *
     * @event reload-drafts
     */

    properties: {
      /** @type {?} */
      patchRange: Object,
      patchNum: String,
      changeNum: String,
      /** @type {?} */
      changeComments: Object,
      drafts: Object,
      revisions: Array,
      projectConfig: Object,
      selectedIndex: {
        type: Number,
        notify: true,
      },
      keyEventTarget: {
        type: Object,
        value() { return document.body; },
      },
      /** @type {?} */
      change: Object,
      diffViewMode: {
        type: String,
        notify: true,
        observer: '_updateDiffPreferences',
      },
      editMode: {
        type: Boolean,
        observer: '_editModeChanged',
      },
      filesExpanded: {
        type: String,
        value: GrFileListConstants.FilesExpandedState.NONE,
        notify: true,
      },
      _filesByPath: Object,
      _files: {
        type: Array,
        observer: '_filesChanged',
        value() { return []; },
      },
      _loggedIn: {
        type: Boolean,
        value: false,
      },
      _reviewed: {
        type: Array,
        value() { return []; },
      },
      diffPrefs: {
        type: Object,
        notify: true,
        observer: '_updateDiffPreferences',
      },
      /** @type {?} */
      _userPrefs: Object,
      _showInlineDiffs: Boolean,
      numFilesShown: {
        type: Number,
        notify: true,
      },
      /** @type {?} */
      _patchChange: {
        type: Object,
        computed: '_calculatePatchChange(_files)',
      },
      fileListIncrement: Number,
      _hideChangeTotals: {
        type: Boolean,
        computed: '_shouldHideChangeTotals(_patchChange)',
      },
      _hideBinaryChangeTotals: {
        type: Boolean,
        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
      },

      _shownFiles: {
        type: Array,
        computed: '_computeFilesShown(numFilesShown, _files.*)',
      },

      /**
       * The amount of files added to the shown files list the last time it was
       * updated. This is used for reporting the average render time.
       */
      _reportinShownFilesIncrement: Number,

      _expandedFilePaths: {
        type: Array,
        value() { return []; },
      },
      _displayLine: Boolean,
      _loading: {
        type: Boolean,
        observer: '_loadingChanged',
      },
      /** @type {Defs.LayoutStats|undefined} */
      _sizeBarLayout: {
        type: Object,
        computed: '_computeSizeBarLayout(_shownFiles.*)',
      },

      _showSizeBars: {
        type: Boolean,
        value: true,
        computed: '_computeShowSizeBars(_userPrefs)',
      },

      /** @type {Function} */
      _cancelForEachDiff: Function,
    },

    behaviors: [
      Gerrit.AsyncForeachBehavior,
      Gerrit.DomUtilBehavior,
      Gerrit.KeyboardShortcutBehavior,
      Gerrit.PatchSetBehavior,
      Gerrit.PathListBehavior,
    ],

    observers: [
      '_expandedPathsChanged(_expandedFilePaths.splices)',
      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
          '_loading)',
    ],

    keyBindings: {
      esc: '_handleEscKey',
    },

    keyboardShortcuts() {
      return {
        [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
        [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
        [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
        [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
        [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
        [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
        [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
        [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
        [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
        [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
        [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
        [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
        [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
        [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
        [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
        [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',

        // Final two are actually handled by gr-diff-comment-thread.
        [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
        [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
      };
    },
    listeners: {
      keydown: '_scopedKeydownHandler',
    },

    detached() {
      this._cancelDiffs();
    },

    /**
     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
     * events must be scoped to a component level (e.g. `enter`) in order to not
     * override native browser functionality.
     *
     * Context: Issue 7277
     */
    _scopedKeydownHandler(e) {
      if (e.keyCode === 13) {
        // Enter.
        this._handleOpenFile(e);
      }
    },

    reload() {
      if (!this.changeNum || !this.patchRange.patchNum) {
        return Promise.resolve();
      }

      this._loading = true;

      this.collapseAllDiffs();
      const promises = [];

      promises.push(this._getFiles().then(filesByPath => {
        this._filesByPath = filesByPath;
      }));
      promises.push(this._getLoggedIn().then(loggedIn => {
        return this._loggedIn = loggedIn;
      }).then(loggedIn => {
        if (!loggedIn) { return; }

        return this._getReviewedFiles().then(reviewed => {
          this._reviewed = reviewed;
        });
      }));

      promises.push(this._getDiffPreferences().then(prefs => {
        this.diffPrefs = prefs;
      }));

      promises.push(this._getPreferences().then(prefs => {
        this._userPrefs = prefs;
      }));

      return Promise.all(promises).then(() => {
        this._loading = false;
        this._detectChromiteButler();
        this.$.reporting.fileListDisplayed();
      });
    },

    _detectChromiteButler() {
      const hasButler = !!document.getElementById('butler-suggested-owners');
      if (hasButler) {
        this.$.reporting.reportExtension('butler');
      }
    },

    get diffs() {
      return Polymer.dom(this.root).querySelectorAll('gr-diff-host');
    },

    openDiffPrefs() {
      this.$.diffPreferencesDialog.open();
    },

    _calculatePatchChange(files) {
      const magicFilesExcluded = files.filter(files => {
        return files.__path !== '/COMMIT_MSG' && files.__path !== '/MERGE_LIST';
      });

      return magicFilesExcluded.reduce((acc, obj) => {
        const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
        const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
        const total_size = (obj.size && obj.binary) ? obj.size : 0;
        const size_delta_inserted =
            obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
        const size_delta_deleted =
            obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;

        return {
          inserted: acc.inserted + inserted,
          deleted: acc.deleted + deleted,
          size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
          size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
          total_size: acc.total_size + total_size,
        };
      }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
        size_delta_deleted: 0, total_size: 0});
    },

    _getDiffPreferences() {
      return this.$.restAPI.getDiffPreferences();
    },

    _getPreferences() {
      return this.$.restAPI.getPreferences();
    },

    _togglePathExpanded(path) {
      // Is the path in the list of expanded diffs? IF so remove it, otherwise
      // add it to the list.
      const pathIndex = this._expandedFilePaths.indexOf(path);
      if (pathIndex === -1) {
        this.push('_expandedFilePaths', path);
      } else {
        this.splice('_expandedFilePaths', pathIndex, 1);
      }
    },

    _togglePathExpandedByIndex(index) {
      this._togglePathExpanded(this._files[index].__path);
    },

    _updateDiffPreferences() {
      if (!this.diffs.length) { return; }
      // Re-render all expanded diffs sequentially.
      this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
      this._renderInOrder(this._expandedFilePaths, this.diffs,
          this._expandedFilePaths.length);
    },

    _forEachDiff(fn) {
      const diffs = this.diffs;
      for (let i = 0; i < diffs.length; i++) {
        fn(diffs[i]);
      }
    },

    expandAllDiffs() {
      this._showInlineDiffs = true;

      // Find the list of paths that are in the file list, but not in the
      // expanded list.
      const newPaths = [];
      let path;
      for (let i = 0; i < this._shownFiles.length; i++) {
        path = this._shownFiles[i].__path;
        if (!this._expandedFilePaths.includes(path)) {
          newPaths.push(path);
        }
      }

      this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
    },

    collapseAllDiffs() {
      this._showInlineDiffs = false;
      this._expandedFilePaths = [];
      this.filesExpanded = this._computeExpandedFiles(
          this._expandedFilePaths.length, this._files.length);
      this.$.diffCursor.handleDiffUpdate();
    },

    /**
     * Computes a string with the number of comments and unresolved comments.
     *
     * @param {!Object} changeComments
     * @param {!Object} patchRange
     * @param {string} path
     * @return {string}
     */
    _computeCommentsString(changeComments, patchRange, path) {
      const unresolvedCount =
          changeComments.computeUnresolvedNum(patchRange.basePatchNum, path) +
          changeComments.computeUnresolvedNum(patchRange.patchNum, path);
      const commentCount =
          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
          changeComments.computeCommentCount(patchRange.patchNum, path);
      const commentString = GrCountStringFormatter.computePluralString(
          commentCount, 'comment');
      const unresolvedString = GrCountStringFormatter.computeString(
          unresolvedCount, 'unresolved');

      return commentString +
          // Add a space if both comments and unresolved
          (commentString && unresolvedString ? ' ' : '') +
          // Add parentheses around unresolved if it exists.
          (unresolvedString ? `(${unresolvedString})` : '');
    },

    /**
     * Computes a string with the number of drafts.
     *
     * @param {!Object} changeComments
     * @param {!Object} patchRange
     * @param {string} path
     * @return {string}
     */
    _computeDraftsString(changeComments, patchRange, path) {
      const draftCount =
          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
          changeComments.computeDraftCount(patchRange.patchNum, path);
      return GrCountStringFormatter.computePluralString(draftCount, 'draft');
    },

    /**
     * Computes a shortened string with the number of drafts.
     *
     * @param {!Object} changeComments
     * @param {!Object} patchRange
     * @param {string} path
     * @return {string}
     */
    _computeDraftsStringMobile(changeComments, patchRange, path) {
      const draftCount =
          changeComments.computeDraftCount(patchRange.basePatchNum, path) +
          changeComments.computeDraftCount(patchRange.patchNum, path);
      return GrCountStringFormatter.computeShortString(draftCount, 'd');
    },

    /**
     * Computes a shortened string with the number of comments.
     *
     * @param {!Object} changeComments
     * @param {!Object} patchRange
     * @param {string} path
     * @return {string}
     */
    _computeCommentsStringMobile(changeComments, patchRange, path) {
      const commentCount =
          changeComments.computeCommentCount(patchRange.basePatchNum, path) +
          changeComments.computeCommentCount(patchRange.patchNum, path);
      return GrCountStringFormatter.computeShortString(commentCount, 'c');
    },

    /**
     * @param {string} path
     * @param {boolean=} opt_reviewed
     */
    _reviewFile(path, opt_reviewed) {
      if (this.editMode) { return; }
      const index = this._files.findIndex(file => file.__path === path);
      const reviewed = opt_reviewed || !this._files[index].isReviewed;

      this.set(['_files', index, 'isReviewed'], reviewed);
      if (index < this._shownFiles.length) {
        this.set(['_shownFiles', index, 'isReviewed'], reviewed);
      }

      this._saveReviewedState(path, reviewed);
    },

    _saveReviewedState(path, reviewed) {
      return this.$.restAPI.saveFileReviewed(this.changeNum,
          this.patchRange.patchNum, path, reviewed);
    },

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

    _getReviewedFiles() {
      if (this.editMode) { return Promise.resolve([]); }
      return this.$.restAPI.getReviewedFiles(this.changeNum,
          this.patchRange.patchNum);
    },

    _getFiles() {
      return this.$.restAPI.getChangeOrEditFiles(
          this.changeNum, this.patchRange);
    },

    /**
     * The closure compiler doesn't realize this.specialFilePathCompare is
     * valid.
     *
     * @suppress {checkTypes}
     */
    _normalizeChangeFilesResponse(response) {
      if (!response) { return []; }
      const paths = Object.keys(response).sort(this.specialFilePathCompare);
      const files = [];
      for (let i = 0; i < paths.length; i++) {
        const info = response[paths[i]];
        info.__path = paths[i];
        info.lines_inserted = info.lines_inserted || 0;
        info.lines_deleted = info.lines_deleted || 0;
        files.push(info);
      }
      return files;
    },

    /**
     * Handle all events from the file list dom-repeat so event handleers don't
     * have to get registered for potentially very long lists.
     */
    _handleFileListTap(e) {
      // Traverse upwards to find the row element if the target is not the row.
      let row = e.target;
      while (!row.classList.contains('row') && row.parentElement) {
        row = row.parentElement;
      }

      const path = row.dataset.path;
      // Handle checkbox mark as reviewed.
      if (e.target.classList.contains('markReviewed')) {
        e.preventDefault();
        return this._reviewFile(path);
      }

      // If a path cannot be interpreted from the click target (meaning it's not
      // somewhere in the row, e.g. diff content) or if the user clicked the
      // link, defer to the native behavior.
      if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }

      // Disregard the event if the click target is in the edit controls.
      if (this.descendedFromClass(e.target, 'editFileControls')) { return; }

      e.preventDefault();
      this._togglePathExpanded(path);
    },

    _handleLeftPane(e) {
      if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
        return;
      }

      e.preventDefault();
      this.$.diffCursor.moveLeft();
    },

    _handleRightPane(e) {
      if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
        return;
      }

      e.preventDefault();
      this.$.diffCursor.moveRight();
    },

    _handleToggleInlineDiff(e) {
      if (this.shouldSuppressKeyboardShortcut(e) ||
          this.modifierPressed(e) ||
          this.$.fileCursor.index === -1) { return; }

      e.preventDefault();
      this._togglePathExpandedByIndex(this.$.fileCursor.index);
    },

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

      e.preventDefault();
      this._toggleInlineDiffs();
    },

    _handleCursorNext(e) {
      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
        return;
      }

      if (this._showInlineDiffs) {
        e.preventDefault();
        this.$.diffCursor.moveDown();
        this._displayLine = true;
      } else {
        // Down key
        if (this.getKeyboardEvent(e).keyCode === 40) { return; }
        e.preventDefault();
        this.$.fileCursor.next();
        this.selectedIndex = this.$.fileCursor.index;
      }
    },

    _handleCursorPrev(e) {
      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
        return;
      }

      if (this._showInlineDiffs) {
        e.preventDefault();
        this.$.diffCursor.moveUp();
        this._displayLine = true;
      } else {
        // Up key
        if (this.getKeyboardEvent(e).keyCode === 38) { return; }
        e.preventDefault();
        this.$.fileCursor.previous();
        this.selectedIndex = this.$.fileCursor.index;
      }
    },

    _handleNewComment(e) {
      if (this.shouldSuppressKeyboardShortcut(e) ||
          this.modifierPressed(e)) { return; }

      const isRangeSelected = this.diffs.some(diff => {
        return diff.isRangeSelected();
      }, this);
      if (!isRangeSelected) {
        e.preventDefault();
        this._addDraftAtTarget();
      }
    },

    _handleOpenLastFile(e) {
      // Check for meta key to avoid overriding native chrome shortcut.
      if (this.shouldSuppressKeyboardShortcut(e) ||
          this.getKeyboardEvent(e).metaKey) { return; }

      e.preventDefault();
      this._openSelectedFile(this._files.length - 1);
    },

    _handleOpenFirstFile(e) {
      // Check for meta key to avoid overriding native chrome shortcut.
      if (this.shouldSuppressKeyboardShortcut(e) ||
          this.getKeyboardEvent(e).metaKey) { return; }

      e.preventDefault();
      this._openSelectedFile(0);
    },

    _handleOpenFile(e) {
      if (this.shouldSuppressKeyboardShortcut(e) ||
          this.modifierPressed(e)) { return; }
      e.preventDefault();

      if (this._showInlineDiffs) {
        this._openCursorFile();
        return;
      }

      this._openSelectedFile();
    },

    _handleNextChunk(e) {
      if (this.shouldSuppressKeyboardShortcut(e) ||
          (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
          this._noDiffsExpanded()) {
        return;
      }

      e.preventDefault();
      if (this.isModifierPressed(e, 'shiftKey')) {
        this.$.diffCursor.moveToNextCommentThread();
      } else {
        this.$.diffCursor.moveToNextChunk();
      }
    },

    _handlePrevChunk(e) {
      if (this.shouldSuppressKeyboardShortcut(e) ||
          (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
          this._noDiffsExpanded()) {
        return;
      }

      e.preventDefault();
      if (this.isModifierPressed(e, 'shiftKey')) {
        this.$.diffCursor.moveToPreviousCommentThread();
      } else {
        this.$.diffCursor.moveToPreviousChunk();
      }
    },

    _handleToggleFileReviewed(e) {
      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
        return;
      }

      e.preventDefault();
      if (!this._files[this.$.fileCursor.index]) { return; }
      this._reviewFile(this._files[this.$.fileCursor.index].__path);
    },

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

      e.preventDefault();
      this._forEachDiff(diff => {
        diff.toggleLeftDiff();
      });
    },

    _toggleInlineDiffs() {
      if (this._showInlineDiffs) {
        this.collapseAllDiffs();
      } else {
        this.expandAllDiffs();
      }
    },

    _openCursorFile() {
      const diff = this.$.diffCursor.getTargetDiffElement();
      Gerrit.Nav.navigateToDiff(this.change, diff.path,
          diff.patchRange.patchNum, this.patchRange.basePatchNum);
    },

    /**
     * @param {number=} opt_index
     */
    _openSelectedFile(opt_index) {
      if (opt_index != null) {
        this.$.fileCursor.setCursorAtIndex(opt_index);
      }
      if (!this._files[this.$.fileCursor.index]) { return; }
      Gerrit.Nav.navigateToDiff(this.change,
          this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
          this.patchRange.basePatchNum);
    },

    _addDraftAtTarget() {
      const diff = this.$.diffCursor.getTargetDiffElement();
      const target = this.$.diffCursor.getTargetLineElement();
      if (diff && target) {
        diff.addDraftAtLine(target);
      }
    },

    _shouldHideChangeTotals(_patchChange) {
      return _patchChange.inserted === 0 && _patchChange.deleted === 0;
    },

    _shouldHideBinaryChangeTotals(_patchChange) {
      return _patchChange.size_delta_inserted === 0 &&
          _patchChange.size_delta_deleted === 0;
    },

    _computeFileStatus(status) {
      return status || 'M';
    },

    _computeDiffURL(change, patchNum, basePatchNum, path, editMode) {
      // TODO(kaspern): Fix editing for commit messages and merge lists.
      if (editMode && path !== this.COMMIT_MESSAGE_PATH &&
          path !== this.MERGE_LIST_PATH) {
        return Gerrit.Nav.getEditUrlForDiff(change, path, patchNum,
            basePatchNum);
      }
      return Gerrit.Nav.getUrlForDiff(change, path, patchNum, basePatchNum);
    },

    _formatBytes(bytes) {
      if (bytes == 0) return '+/-0 B';
      const bits = 1024;
      const decimals = 1;
      const sizes =
          ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
      const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
      const prepend = bytes > 0 ? '+' : '';
      return prepend + parseFloat((bytes / Math.pow(bits, exponent))
          .toFixed(decimals)) + ' ' + sizes[exponent];
    },

    _formatPercentage(size, delta) {
      const oldSize = size - delta;

      if (oldSize === 0) { return ''; }

      const percentage = Math.round(Math.abs(delta * 100 / oldSize));
      return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
    },

    _computeBinaryClass(delta) {
      if (delta === 0) { return; }
      return delta >= 0 ? 'added' : 'removed';
    },

    /**
     * @param {string} baseClass
     * @param {string} path
     */
    _computeClass(baseClass, path) {
      const classes = [baseClass];
      if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
        classes.push('invisible');
      }
      return classes.join(' ');
    },

    _computePathClass(path, expandedFilesRecord) {
      return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
    },

    _computeShowHideIcon(path, expandedFilesRecord) {
      return this._isFileExpanded(path, expandedFilesRecord) ?
        'gr-icons:expand-less' : 'gr-icons:expand-more';
    },

    _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
      // Await all promises resolving from reload. @See Issue 9057
      if (loading) { return; }

      const commentedPaths = changeComments.getPaths(patchRange);
      const files = Object.assign({}, filesByPath);
      Object.keys(commentedPaths).forEach(commentedPath => {
        if (files.hasOwnProperty(commentedPath)) { return; }
        files[commentedPath] = {status: 'U'};
      });
      const reviewedSet = new Set(reviewed || []);
      for (const filePath in files) {
        if (!files.hasOwnProperty(filePath)) { continue; }
        files[filePath].isReviewed = reviewedSet.has(filePath);
      }

      this._files = this._normalizeChangeFilesResponse(files);
    },

    _computeFilesShown(numFilesShown, files) {
      const previousNumFilesShown = this._shownFiles ?
        this._shownFiles.length : 0;

      const filesShown = files.base.slice(0, numFilesShown);
      this.fire('files-shown-changed', {length: filesShown.length});

      // Start the timer for the rendering work hwere because this is where the
      // _shownFiles property is being set, and _shownFiles is used in the
      // dom-repeat binding.
      this.$.reporting.time(RENDER_TIMING_LABEL);

      // How many more files are being shown (if it's an increase).
      this._reportinShownFilesIncrement =
          Math.max(0, filesShown.length - previousNumFilesShown);

      return filesShown;
    },

    _updateDiffCursor() {
      // Overwrite the cursor's list of diffs:
      this.$.diffCursor.splice(
          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
    },

    _filesChanged() {
      Polymer.dom.flush();
      const files = Polymer.dom(this.root).querySelectorAll('.file-row');
      this.$.fileCursor.stops = files;
      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
    },

    _incrementNumFilesShown() {
      this.numFilesShown += this.fileListIncrement;
    },

    _computeFileListControlClass(numFilesShown, files) {
      return numFilesShown >= files.length ? 'invisible' : '';
    },

    _computeIncrementText(numFilesShown, files) {
      if (!files) { return ''; }
      const text =
          Math.min(this.fileListIncrement, files.length - numFilesShown);
      return 'Show ' + text + ' more';
    },

    _computeShowAllText(files) {
      if (!files) { return ''; }
      return 'Show all ' + files.length + ' files';
    },

    _computeWarnShowAll(files) {
      return files.length > WARN_SHOW_ALL_THRESHOLD;
    },

    _computeShowAllWarning(files) {
      if (!this._computeWarnShowAll(files)) { return ''; }
      return 'Warning: showing all ' + files.length +
          ' files may take several seconds.';
    },

    _showAllFiles() {
      this.numFilesShown = this._files.length;
    },

    _computePatchSetDescription(revisions, patchNum) {
      const rev = this.getRevisionByPatchNum(revisions, patchNum);
      return (rev && rev.description) ?
        rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
    },

    /**
     * Get a descriptive label for use in the status indicator's tooltip and
     * ARIA label.
     *
     * @param {string} status
     * @return {string}
     */
    _computeFileStatusLabel(status) {
      const statusCode = this._computeFileStatus(status);
      return FileStatus.hasOwnProperty(statusCode) ?
        FileStatus[statusCode] : 'Status Unknown';
    },

    _isFileExpanded(path, expandedFilesRecord) {
      return expandedFilesRecord.base.includes(path);
    },

    _onLineSelected(e, detail) {
      this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
          detail.path);
    },

    _computeExpandedFiles(expandedCount, totalCount) {
      if (expandedCount === 0) {
        return GrFileListConstants.FilesExpandedState.NONE;
      } else if (expandedCount === totalCount) {
        return GrFileListConstants.FilesExpandedState.ALL;
      }
      return GrFileListConstants.FilesExpandedState.SOME;
    },

    /**
     * Handle splices to the list of expanded file paths. If there are any new
     * entries in the expanded list, then render each diff corresponding in
     * order by waiting for the previous diff to finish before starting the next
     * one.
     *
     * @param {!Array} record The splice record in the expanded paths list.
     */
    _expandedPathsChanged(record) {
      // Clear content for any diffs that are not open so if they get re-opened
      // the stale content does not flash before it is cleared and reloaded.
      const collapsedDiffs = this.diffs.filter(diff =>
        this._expandedFilePaths.indexOf(diff.path) === -1);
      this._clearCollapsedDiffs(collapsedDiffs);

      if (!record) { return; } // Happens after "Collapse all" clicked.

      this.filesExpanded = this._computeExpandedFiles(
          this._expandedFilePaths.length, this._files.length);

      // Find the paths introduced by the new index splices:
      const newPaths = record.indexSplices
          .map(splice => splice.object.slice(
              splice.index, splice.index + splice.addedCount))
          .reduce((acc, paths) => acc.concat(paths), []);

      // Required so that the newly created diff view is included in this.diffs.
      Polymer.dom.flush();

      this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);

      if (newPaths.length) {
        this._renderInOrder(newPaths, this.diffs, newPaths.length);
      }

      this._updateDiffCursor();
      this.$.diffCursor.handleDiffUpdate();
    },

    _clearCollapsedDiffs(collapsedDiffs) {
      for (const diff of collapsedDiffs) {
        diff.cancel();
        diff.clearDiffContent();
      }
    },

    /**
     * Given an array of paths and a NodeList of diff elements, render the diff
     * for each path in order, awaiting the previous render to complete before
     * continung.
     *
     * @param  {!Array<string>} paths
     * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
     * @param  {number} initialCount The total number of paths in the pass. This
     *   is used to generate log messages.
     * @return {!Promise}
     */
    _renderInOrder(paths, diffElements, initialCount) {
      let iter = 0;

      return (new Promise(resolve => {
        this.fire('reload-drafts', {resolve});
      })).then(() => {
        return this.asyncForeach(paths, (path, cancel) => {
          this._cancelForEachDiff = cancel;

          iter++;
          console.log('Expanding diff', iter, 'of', initialCount, ':',
              path);
          const diffElem = this._findDiffByPath(path, diffElements);
          diffElem.comments = this.changeComments.getCommentsBySideForPath(
              path, this.patchRange, this.projectConfig);
          const promises = [diffElem.reload()];
          if (this._loggedIn && !this.diffPrefs.manual_review) {
            promises.push(this._reviewFile(path, true));
          }
          return Promise.all(promises);
        }).then(() => {
          this._cancelForEachDiff = null;
          this._nextRenderParams = null;
          console.log('Finished expanding', initialCount, 'diff(s)');
          this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
              EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
          this.$.diffCursor.handleDiffUpdate();
        });
      });
    },

    /** Cancel the rendering work of every diff in the list */
    _cancelDiffs() {
      if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
      this._forEachDiff(d => d.cancel());
    },

    /**
     * In the given NodeList of diff elements, find the diff for the given path.
     *
     * @param  {string} path
     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
     * @return {!Object|undefined} (GrDiffElement)
     */
    _findDiffByPath(path, diffElements) {
      for (let i = 0; i < diffElements.length; i++) {
        if (diffElements[i].path === path) {
          return diffElements[i];
        }
      }
    },

    /**
     * Reset the comments of a modified thread
     *
     * @param  {string} rootId
     * @param  {string} path
     */
    reloadCommentsForThreadWithRootId(rootId, path) {
      // Don't bother continuing if we already know that the path that contains
      // the updated comment thread is not expanded.
      if (!this._expandedFilePaths.includes(path)) { return; }
      const diff = this.diffs.find(d => d.path === path);

      const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
      if (!threadEl) { return; }

      const newComments = this.changeComments.getCommentsForThread(rootId);

      // If newComments is null, it means that a single draft was
      // removed from a thread in the thread view, and the thread should
      // no longer exist. Remove the existing thread element in the diff
      // view.
      if (!newComments) {
        threadEl.fireRemoveSelf();
        return;
      }

      // Comments are not returned with the commentSide attribute from
      // the api, but it's necessary to be stored on the diff's
      // comments due to use in the _handleCommentUpdate function.
      // The comment thread already has a side associated with it, so
      // set the comment's side to match.
      threadEl.comments = newComments.map(c => {
        return Object.assign(c, {__commentSide: threadEl.commentSide});
      });
      Polymer.dom.flush();
      return;
    },

    _handleEscKey(e) {
      if (this.shouldSuppressKeyboardShortcut(e) ||
          this.modifierPressed(e)) { return; }
      e.preventDefault();
      this._displayLine = false;
    },

    /**
     * Update the loading class for the file list rows. The update is inside a
     * debouncer so that the file list doesn't flash gray when the API requests
     * are reasonably fast.
     *
     * @param {boolean} loading
     */
    _loadingChanged(loading) {
      this.debounce('loading-change', () => {
        // Only show set the loading if there have been files loaded to show. In
        // this way, the gray loading style is not shown on initial loads.
        this.classList.toggle('loading', loading && !!this._files.length);
      }, LOADING_DEBOUNCE_INTERVAL);
    },

    _editModeChanged(editMode) {
      this.classList.toggle('editMode', editMode);
    },

    _computeReviewedClass(isReviewed) {
      return isReviewed ? 'isReviewed' : '';
    },

    _computeReviewedText(isReviewed) {
      return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
    },

    /**
     * Given a file path, return whether that path should have visible size bars
     * and be included in the size bars calculation.
     *
     * @param {string} path
     * @return {boolean}
     */
    _showBarsForPath(path) {
      return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
    },

    /**
     * Compute size bar layout values from the file list.
     *
     * @return {Defs.LayoutStats|undefined}
     */
    _computeSizeBarLayout(shownFilesRecord) {
      if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
      const stats = {
        maxInserted: 0,
        maxDeleted: 0,
        maxAdditionWidth: 0,
        maxDeletionWidth: 0,
        deletionOffset: 0,
      };
      shownFilesRecord.base
          .filter(f => this._showBarsForPath(f.__path))
          .forEach(f => {
            if (f.lines_inserted) {
              stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
            }
            if (f.lines_deleted) {
              stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
            }
          });
      const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
      if (!isNaN(ratio)) {
        stats.maxAdditionWidth =
            (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
        stats.maxDeletionWidth =
            SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
        stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
      }
      return stats;
    },

    /**
     * Get the width of the addition bar for a file.
     *
     * @param {Object} file
     * @param {Defs.LayoutStats} stats
     * @return {number}
     */
    _computeBarAdditionWidth(file, stats) {
      if (stats.maxInserted === 0 ||
          !file.lines_inserted ||
          !this._showBarsForPath(file.__path)) {
        return 0;
      }
      const width =
          stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
      return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
    },

    /**
     * Get the x-offset of the addition bar for a file.
     *
     * @param {Object} file
     * @param {Defs.LayoutStats} stats
     * @return {number}
     */
    _computeBarAdditionX(file, stats) {
      return stats.maxAdditionWidth -
          this._computeBarAdditionWidth(file, stats);
    },

    /**
     * Get the width of the deletion bar for a file.
     *
     * @param {Object} file
     * @param {Defs.LayoutStats} stats
     * @return {number}
     */
    _computeBarDeletionWidth(file, stats) {
      if (stats.maxDeleted === 0 ||
          !file.lines_deleted ||
          !this._showBarsForPath(file.__path)) {
        return 0;
      }
      const width =
          stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
      return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
    },

    /**
     * Get the x-offset of the deletion bar for a file.
     *
     * @param {Defs.LayoutStats} stats
     * @return {number}
     */
    _computeBarDeletionX(stats) {
      return stats.deletionOffset;
    },

    _computeShowSizeBars(userPrefs) {
      return !!userPrefs.size_bar_in_change_table;
    },

    _computeSizeBarsClass(showSizeBars, path) {
      let hideClass = '';
      if (!showSizeBars) {
        hideClass = 'hide';
      } else if (!this._showBarsForPath(path)) {
        hideClass = 'invisible';
      }
      return `sizeBars desktop ${hideClass}`;
    },

    /**
     * Returns true if none of the inline diffs have been expanded.
     *
     * @return {boolean}
     */
    _noDiffsExpanded() {
      return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
    },

    /**
     * Method to call via binding when each file list row is rendered. This
     * allows approximate detection of when the dom-repeat has completed
     * rendering.
     *
     * @param {number} index The index of the row being rendered.
     * @return {string} an empty string.
     */
    _reportRenderedRow(index) {
      if (index === this._shownFiles.length - 1) {
        this.async(() => {
          this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
              RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
        }, 1);
      }
      return '';
    },

    _reviewedTitle(reviewed) {
      if (reviewed) {
        return 'Mark as not reviewed (shortcut: r)';
      }

      return 'Mark as reviewed (shortcut: r)';
    },

    _handleReloadingDiffPreference() {
      this._getDiffPreferences().then(prefs => {
        this.diffPrefs = prefs;
      });
    },
  });
})();
