/**
 * @license
 * Copyright (C) 2018 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.
 */
import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
import '../../shared/gr-comment-thread/gr-comment-thread';
import '../../shared/gr-js-api-interface/gr-js-api-interface';
import '../gr-diff/gr-diff';
import '../gr-syntax-layer/gr-syntax-layer';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-diff-host_html';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {rangesEqual} from '../gr-diff/gr-diff-utils';
import {appContext} from '../../../services/app-context';
import {
  getParentIndex,
  isMergeParent,
  isNumber,
} from '../../../utils/patch-set-util';
import {
  Comment,
  isDraft,
  sortComments,
  UIComment,
} from '../../../utils/comment-util';
import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
import {customElement, observe, property} from '@polymer/decorators';
import {
  CommitRange,
  CoverageRange,
  DiffLayer,
  DiffLayerListener,
} from '../../../types/types';
import {
  Base64ImageFile,
  BlameInfo,
  ChangeInfo,
  CommentRange,
  DiffInfo,
  DiffPreferencesInfo,
  NumericChangeId,
  PatchRange,
  PatchSetNum,
  RepoName,
} from '../../../types/common';
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
import {
  DiffViewMode,
  IgnoreWhitespaceType,
  Side,
} from '../../../constants/constants';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
import {LineNumber} from '../gr-diff/gr-diff-line';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
import {PatchSetFile} from '../../../types/types';
import {KnownExperimentId} from '../../../services/flags/flags';

const MSG_EMPTY_BLAME = 'No blame information for this diff.';

const EVENT_AGAINST_PARENT = 'diff-against-parent';
const EVENT_ZERO_REBASE = 'rebase-percent-zero';
const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';

const TimingLabel = {
  TOTAL: 'Diff Total Render',
  CONTENT: 'Diff Content Render',
  SYNTAX: 'Diff Syntax Render',
};

// Disable syntax highlighting if the overall diff is too large.
const SYNTAX_MAX_DIFF_LENGTH = 20000;

// If any line of the diff is more than the character limit, then disable
// syntax highlighting for the entire file.
const SYNTAX_MAX_LINE_LENGTH = 500;

// 120 lines is good enough threshold for full-sized window viewport
const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;

function isImageDiff(diff?: DiffInfo) {
  if (!diff) return false;

  const isA = diff.meta_a && diff.meta_a.content_type.startsWith('image/');
  const isB = diff.meta_b && diff.meta_b.content_type.startsWith('image/');

  return !!(diff.binary && (isA || isB));
}

interface LineInfo {
  beforeNumber?: LineNumber;
  afterNumber?: LineNumber;
}

// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
// What is being used here is just a local object for collecting all the data
// that is needed to create a GrCommentThread component, see
// _createThreadElement().
interface CommentThread {
  comments: UIComment[];
  // In the context of a diff each thread must have a side!
  commentSide: Side;
  patchNum?: PatchSetNum;
  lineNum?: LineNumber;
  isOnParent?: boolean;
  range?: CommentRange;
}

export interface GrDiffHost {
  $: {
    restAPI: RestApiService & Element;
    jsAPI: JsApiService & Element;
    syntaxLayer: GrSyntaxLayer & Element;
    diff: GrDiff;
  };
}

/**
 * Wrapper around gr-diff.
 *
 * Webcomponent fetching diffs and related data from restAPI and passing them
 * to the presentational gr-diff for rendering. <gr-diff-host> is a Gerrit
 * specific component, while <gr-diff> is a re-usable component.
 */
@customElement('gr-diff-host')
export class GrDiffHost extends GestureEventListeners(
  LegacyElementMixin(PolymerElement)
) {
  static get template() {
    return htmlTemplate;
  }

  /**
   * Fired when the user selects a line.
   *
   * @event line-selected
   */

  /**
   * Fired if being logged in is required.
   *
   * @event show-auth-required
   */

  /**
   * Fired when a comment is saved or discarded
   *
   * @event diff-comments-modified
   */

  @property({type: Number})
  changeNum?: NumericChangeId;

  @property({type: Object})
  change?: ChangeInfo;

  @property({type: Boolean})
  noAutoRender = false;

  @property({type: Object})
  patchRange?: PatchRange;

  @property({type: Object})
  file?: PatchSetFile;

  @property({type: String})
  path?: string;

  @property({type: Object})
  prefs?: DiffPreferencesInfo;

  @property({type: String})
  projectName?: RepoName;

  @property({type: Boolean})
  displayLine = false;

  @property({
    type: Boolean,
    computed: '_computeIsImageDiff(diff)',
    notify: true,
  })
  isImageDiff?: boolean;

  @property({type: Object})
  commitRange?: CommitRange;

  @property({type: Object, notify: true})
  filesWeblinks: FilesWebLinks | {} = {};

  @property({type: Boolean, reflectToAttribute: true})
  hidden = false;

  @property({type: Boolean})
  noRenderOnPrefsChange = false;

  @property({type: Object, observer: '_commentsChanged'})
  comments?: TwoSidesComments;

  @property({type: Boolean})
  lineWrapping = false;

  @property({type: String})
  viewMode = DiffViewMode.SIDE_BY_SIDE;

  @property({type: Object})
  lineOfInterest?: LineOfInterest;

  @property({type: Boolean})
  showLoadFailure?: boolean;

  @property({
    type: Boolean,
    notify: true,
    computed: '_computeIsBlameLoaded(_blame)',
  })
  isBlameLoaded?: boolean;

  @property({type: Boolean})
  _loggedIn = false;

  @property({type: String})
  _errorMessage: string | null = null;

  @property({type: Object})
  _baseImage: Base64ImageFile | null = null;

  @property({type: Object})
  _revisionImage: Base64ImageFile | null = null;

  @property({type: Object, notify: true})
  diff?: DiffInfo;

  @property({type: Object})
  _fetchDiffPromise: Promise<DiffInfo> | null = null;

  @property({type: Object})
  _blame: BlameInfo[] | null = null;

  @property({type: Array})
  _coverageRanges: CoverageRange[] = [];

  @property({type: String})
  _loadedWhitespaceLevel?: IgnoreWhitespaceType;

  @property({type: Number, computed: '_computeParentIndex(patchRange.*)'})
  _parentIndex: number | null = null;

  @property({
    type: Boolean,
    computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
  })
  _syntaxHighlightingEnabled?: boolean;

  @property({type: Array})
  _layers: DiffLayer[] = [];

  private readonly reporting = appContext.reportingService;

  private readonly flags = appContext.flagsService;

  /** @override */
  created() {
    super.created();
    this.addEventListener(
      // These are named inconsistently for a reason:
      // The create-comment event is fired to indicate that we should
      // create a comment.
      // The comment-* events are just notifying that the comments did already
      // change in some way, and that we should update any models we may want
      // to keep in sync.
      'create-comment',
      e => this._handleCreateComment(e)
    );
    this.addEventListener('comment-discard', e =>
      this._handleCommentDiscard(e)
    );
    this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
    this.addEventListener('comment-save', e => this._handleCommentSave(e));
    this.addEventListener('render-start', () => this._handleRenderStart());
    this.addEventListener('render-content', () => this._handleRenderContent());
    this.addEventListener('normalize-range', event =>
      this._handleNormalizeRange(event)
    );
    this.addEventListener('diff-context-expanded', event =>
      this._handleDiffContextExpanded(event)
    );
  }

  /** @override */
  ready() {
    super.ready();
    if (this._canReload()) {
      this.reload();
    }
  }

  /** @override */
  attached() {
    super.attached();
    this._getLoggedIn().then(loggedIn => {
      this._loggedIn = loggedIn;
    });
  }

  /** @override */
  detached() {
    super.detached();
    this.clear();
  }

  /**
   * @param shouldReportMetric indicate a new Diff Page. This is a
   * signal to report metrics event that started on location change.
   * @return
   */
  async reload(shouldReportMetric?: boolean) {
    this.clear();
    if (!this.path) throw new Error('Missing required "path" property.');
    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
    this.diff = undefined;
    this._errorMessage = null;
    const whitespaceLevel = this._getIgnoreWhitespace();

    this._layers = this._getLayers(this.path, this.changeNum);

    if (shouldReportMetric) {
      // We listen on render viewport only on DiffPage (on paramsChanged)
      this._listenToViewportRender();
    }

    this._coverageRanges = [];
    this._getCoverageData();

    try {
      const diff = await this._getDiff();
      this._loadedWhitespaceLevel = whitespaceLevel;
      this._reportDiff(diff);

      await this._loadDiffAssets(diff);

      // Not waiting for coverage ranges intentionally as
      // plugin loading should not block the content rendering

      this.filesWeblinks = this._getFilesWeblinks(diff);
      this.diff = diff;
      const event = await this._onRenderOnce();
      if (shouldReportMetric) {
        // We report diffViewContentDisplayed only on reload caused
        // by params changed - expected only on Diff Page.
        this.reporting.diffViewContentDisplayed();
      }
      const needsSyntaxHighlighting = !!event.detail?.contentRendered;
      if (needsSyntaxHighlighting) {
        this.reporting.time(TimingLabel.SYNTAX);
        try {
          await this.$.syntaxLayer.process();
        } finally {
          this.reporting.timeEnd(TimingLabel.SYNTAX);
        }
      }
    } catch (e) {
      if (e instanceof Response) {
        this._handleGetDiffError(e);
      } else {
        console.warn('Error encountered loading diff:', e);
      }
    } finally {
      this.reporting.timeEnd(TimingLabel.TOTAL);
    }
  }

  private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
    // Get layers from plugins (if any).
    return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
  }

  private _onRenderOnce(): Promise<CustomEvent> {
    return new Promise<CustomEvent>(resolve => {
      const callback = (event: CustomEvent) => {
        this.removeEventListener('render', callback);
        resolve(event);
      };
      this.addEventListener('render', callback);
    });
  }

  clear() {
    if (this.path) this.$.jsAPI.disposeDiffLayers(this.path);
    this._layers = [];
  }

  _getCoverageData() {
    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
    if (!this.change) throw new Error('Missing required "change" prop.');
    if (!this.path) throw new Error('Missing required "path" prop.');
    if (!this.patchRange) throw new Error('Missing required "patchRange".');
    const changeNum = this.changeNum;
    const change = this.change;
    const path = this.path;
    // Coverage providers do not provide data for EDIT and PARENT patch sets.

    const toNumberOnly = (patchNum: PatchSetNum) =>
      isNumber(patchNum) ? patchNum : undefined;

    const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
    const patchNum = toNumberOnly(this.patchRange.patchNum);
    this.$.jsAPI
      .getCoverageAnnotationApis()
      .then(coverageAnnotationApis => {
        coverageAnnotationApis.forEach(coverageAnnotationApi => {
          const provider = coverageAnnotationApi.getCoverageProvider();
          if (!provider) return;
          provider(changeNum, path, basePatchNum, patchNum, change)
            .then(coverageRanges => {
              if (!this.patchRange) throw new Error('Missing "patchRange".');
              if (
                !coverageRanges ||
                changeNum !== this.changeNum ||
                change !== this.change ||
                path !== this.path ||
                basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
                patchNum !== toNumberOnly(this.patchRange.patchNum)
              ) {
                return;
              }

              const existingCoverageRanges = this._coverageRanges;
              this._coverageRanges = coverageRanges;

              // Notify with existing coverage ranges in case there is some
              // existing coverage data that needs to be removed
              existingCoverageRanges.forEach(range => {
                coverageAnnotationApi.notify(
                  path,
                  range.code_range.start_line,
                  range.code_range.end_line,
                  range.side
                );
              });

              // Notify with new coverage data
              coverageRanges.forEach(range => {
                coverageAnnotationApi.notify(
                  path,
                  range.code_range.start_line,
                  range.code_range.end_line,
                  range.side
                );
              });
            })
            .catch(err => {
              console.warn('Applying coverage from provider failed: ', err);
            });
        });
      })
      .catch(err => {
        console.warn('Loading coverage ranges failed: ', err);
      });
  }

  _getFilesWeblinks(diff: DiffInfo) {
    if (!this.projectName || !this.commitRange || !this.path) return {};
    return {
      meta_a: GerritNav.getFileWebLinks(
        this.projectName,
        this.commitRange.baseCommit,
        this.path,
        {weblinks: diff && diff.meta_a && diff.meta_a.web_links}
      ),
      meta_b: GerritNav.getFileWebLinks(
        this.projectName,
        this.commitRange.commit,
        this.path,
        {weblinks: diff && diff.meta_b && diff.meta_b.web_links}
      ),
    };
  }

  /** Cancel any remaining diff builder rendering work. */
  cancel() {
    this.$.diff.cancel();
    this.$.syntaxLayer.cancel();
  }

  getCursorStops() {
    return this.$.diff.getCursorStops();
  }

  isRangeSelected() {
    return this.$.diff.isRangeSelected();
  }

  createRangeComment() {
    return this.$.diff.createRangeComment();
  }

  toggleLeftDiff() {
    this.$.diff.toggleLeftDiff();
  }

  /**
   * Load and display blame information for the base of the diff.
   */
  loadBlame(): Promise<BlameInfo[]> {
    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
    if (!this.patchRange) throw new Error('Missing required "patchRange".');
    if (!this.path) throw new Error('Missing required "path" property.');
    return this.$.restAPI
      .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
      .then(blame => {
        if (!blame || !blame.length) {
          this.dispatchEvent(
            new CustomEvent('show-alert', {
              detail: {message: MSG_EMPTY_BLAME},
              composed: true,
              bubbles: true,
            })
          );
          return Promise.reject(MSG_EMPTY_BLAME);
        }

        this._blame = blame;
        return blame;
      });
  }

  clearBlame() {
    this._blame = null;
  }

  getThreadEls(): GrCommentThread[] {
    return Array.from(this.$.diff.querySelectorAll('.comment-thread'));
  }

  addDraftAtLine(el: Element) {
    this.$.diff.addDraftAtLine(el);
  }

  clearDiffContent() {
    this.$.diff.clearDiffContent();
  }

  expandAllContext() {
    this.$.diff.expandAllContext();
  }

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

  _canReload() {
    return (
      !!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
    );
  }

  // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
  prefetchDiff() {
    if (
      !!this.changeNum &&
      !!this.patchRange &&
      !!this.path &&
      this._fetchDiffPromise === null
    ) {
      this._fetchDiffPromise = this._getDiff();
    }
  }

  _getDiff(): Promise<DiffInfo> {
    if (this._fetchDiffPromise !== null) {
      const fetchDiffPromise = this._fetchDiffPromise;
      this._fetchDiffPromise = null;
      return fetchDiffPromise;
    }
    // Wrap the diff request in a new promise so that the error handler
    // rejects the promise, allowing the error to be handled in the .catch.
    return new Promise((resolve, reject) => {
      if (!this.changeNum) throw new Error('Missing required "changeNum".');
      if (!this.patchRange) throw new Error('Missing required "patchRange".');
      if (!this.path) throw new Error('Missing required "path" property.');
      this.$.restAPI
        .getDiff(
          this.changeNum,
          this.patchRange.basePatchNum,
          this.patchRange.patchNum,
          this.path,
          this._getIgnoreWhitespace(),
          reject
        )
        .then(resolve);
    });
  }

  _handleGetDiffError(response: Response) {
    // Loading the diff may respond with 409 if the file is too large. In this
    // case, use a toast error..
    if (response.status === 409) {
      this.dispatchEvent(
        new CustomEvent('server-error', {
          detail: {response},
          composed: true,
          bubbles: true,
        })
      );
      return;
    }

    if (this.showLoadFailure) {
      this._errorMessage = [
        'Encountered error when loading the diff:',
        response.status,
        response.statusText,
      ].join(' ');
      return;
    }

    this.dispatchEvent(
      new CustomEvent('page-error', {
        detail: {response},
        composed: true,
        bubbles: true,
      })
    );
  }

  /**
   * Report info about the diff response.
   */
  _reportDiff(diff?: DiffInfo) {
    if (!diff || !diff.content) return;

    // Count the delta lines stemming from normal deltas, and from
    // due_to_rebase deltas.
    let nonRebaseDelta = 0;
    let rebaseDelta = 0;
    diff.content.forEach(chunk => {
      if (chunk.ab) {
        return;
      }
      const deltaSize = Math.max(
        chunk.a ? chunk.a.length : 0,
        chunk.b ? chunk.b.length : 0
      );
      if (chunk.due_to_rebase) {
        rebaseDelta += deltaSize;
      } else {
        nonRebaseDelta += deltaSize;
      }
    });

    // Find the percent of the delta from due_to_rebase chunks rounded to two
    // digits. Diffs with no delta are considered 0%.
    const totalDelta = rebaseDelta + nonRebaseDelta;
    const percentRebaseDelta = !totalDelta
      ? 0
      : Math.round((100 * rebaseDelta) / totalDelta);

    // Report the due_to_rebase percentage in the "diff" category when
    // applicable.
    if (!this.patchRange) throw new Error('Missing required "patchRange".');
    if (this.patchRange.basePatchNum === 'PARENT') {
      this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
    } else if (percentRebaseDelta === 0) {
      this.reporting.reportInteraction(EVENT_ZERO_REBASE);
    } else {
      this.reporting.reportInteraction(EVENT_NONZERO_REBASE, {
        percentRebaseDelta,
      });
    }
  }

  _loadDiffAssets(diff?: DiffInfo) {
    if (isImageDiff(diff)) {
      // diff! is justified, because isImageDiff() returns false otherwise
      return this._getImages(diff!).then(images => {
        this._baseImage = images.baseImage;
        this._revisionImage = images.revisionImage;
      });
    } else {
      this._baseImage = null;
      this._revisionImage = null;
      return Promise.resolve();
    }
  }

  _computeIsImageDiff(diff?: DiffInfo) {
    return isImageDiff(diff);
  }

  _commentsChanged(newComments: TwoSidesComments) {
    const allComments = [];
    for (const side of [Side.LEFT, Side.RIGHT]) {
      // This is needed by the threading.
      for (const comment of newComments[side]) {
        comment.__commentSide = side;
      }
      allComments.push(...newComments[side]);
    }
    // Currently, the only way this is ever changed here is when the initial
    // comments are loaded, so it's okay performance wise to clear the threads
    // and recreate them. If this changes in future, we might want to reuse
    // some DOM nodes here.
    this._clearThreads();
    const threads = this._createThreads(allComments);
    for (const thread of threads) {
      const threadEl = this._createThreadElement(thread);
      this._attachThreadElement(threadEl);
    }
  }

  _createThreads(comments: UIComment[]): CommentThread[] {
    const sortedComments = sortComments(comments);
    const threads = [];
    for (const comment of sortedComments) {
      // If the comment is in reply to another comment, find that comment's
      // thread and append to it.
      if (comment.in_reply_to) {
        const thread = threads.find(thread =>
          thread.comments.some(c => c.id === comment.in_reply_to)
        );
        if (thread) {
          thread.comments.push(comment);
          continue;
        }
      }

      // Otherwise, this comment starts its own thread.
      if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
      const newThread: CommentThread = {
        comments: [comment],
        commentSide: comment.__commentSide,
        patchNum: comment.patch_set,
        lineNum: comment.line,
        isOnParent: comment.side === 'PARENT',
      };
      if (comment.range) {
        newThread.range = {...comment.range};
      }
      threads.push(newThread);
    }
    return threads;
  }

  _computeIsBlameLoaded(blame: BlameInfo[] | null) {
    return !!blame;
  }

  _getImages(diff: DiffInfo) {
    if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
    if (!this.patchRange) throw new Error('Missing required "patchRange".');
    return this.$.restAPI.getImagesForDiff(
      this.changeNum,
      diff,
      this.patchRange
    );
  }

  _handleCreateComment(e: CustomEvent) {
    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
    const threadEl = this._getOrCreateThread(
      patchNum,
      lineNum,
      side,
      range,
      isOnParent
    );
    threadEl.addOrEditDraft(lineNum, range);

    this.reporting.recordDraftInteraction();
  }

  /**
   * Gets or creates a comment thread at a given location.
   * May provide a range, to get/create a range comment.
   */
  _getOrCreateThread(
    patchNum: PatchSetNum,
    lineNum: LineNumber | undefined,
    commentSide: Side,
    range?: CommentRange,
    isOnParent?: boolean
  ): GrCommentThread {
    let threadEl = this._getThreadEl(lineNum, commentSide, range);
    if (!threadEl) {
      threadEl = this._createThreadElement({
        comments: [],
        commentSide,
        patchNum,
        lineNum,
        range,
        isOnParent,
      });
      this._attachThreadElement(threadEl);
    }
    return threadEl;
  }

  _attachThreadElement(threadEl: Element) {
    this.$.diff.appendChild(threadEl);
  }

  _clearThreads() {
    for (const threadEl of this.getThreadEls()) {
      const parent = threadEl.parentNode;
      if (parent) parent.removeChild(threadEl);
    }
  }

  _createThreadElement(thread: CommentThread) {
    const threadEl = document.createElement('gr-comment-thread');
    threadEl.className = 'comment-thread';
    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
    threadEl.comments = thread.comments;
    threadEl.commentSide = thread.commentSide;
    threadEl.isOnParent = !!thread.isOnParent;
    threadEl.parentIndex = this._parentIndex;
    // Use path before renmaing when comment added on the left when comparing
    // two patch sets (not against base)
    if (
      this.file &&
      this.file.basePath &&
      thread.commentSide === Side.LEFT &&
      !thread.isOnParent
    ) {
      threadEl.path = this.file.basePath;
    } else {
      threadEl.path = this.path;
    }
    threadEl.changeNum = this.changeNum;
    threadEl.patchNum = thread.patchNum;
    threadEl.showPatchset = false;
    // GrCommentThread does not understand 'FILE', but requires undefined.
    threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
    threadEl.projectName = this.projectName;
    threadEl.range = thread.range;
    const threadDiscardListener = (e: Event) => {
      const threadEl = e.currentTarget as Element;
      const parent = threadEl.parentNode;
      if (parent) parent.removeChild(threadEl);
      threadEl.removeEventListener('thread-discard', threadDiscardListener);
    };
    threadEl.addEventListener('thread-discard', threadDiscardListener);
    return threadEl;
  }

  /**
   * Gets a comment thread element at a given location.
   * May provide a range, to get a range comment.
   */
  _getThreadEl(
    lineNum: LineNumber | undefined,
    commentSide: Side,
    range?: CommentRange
  ): GrCommentThread | null {
    let line: LineInfo;
    if (commentSide === Side.LEFT) {
      line = {beforeNumber: lineNum};
    } else if (commentSide === Side.RIGHT) {
      line = {afterNumber: lineNum};
    } else {
      throw new Error(`Unknown side: ${commentSide}`);
    }
    function matchesRange(threadEl: GrCommentThread) {
      const rangeAtt = threadEl.getAttribute('range');
      const threadRange = rangeAtt
        ? (JSON.parse(rangeAtt) as CommentRange)
        : undefined;
      return rangesEqual(threadRange, range);
    }

    const filteredThreadEls = this._filterThreadElsForLocation(
      this.getThreadEls(),
      line,
      commentSide
    ).filter(matchesRange);
    return filteredThreadEls.length ? filteredThreadEls[0] : null;
  }

  _filterThreadElsForLocation(
    threadEls: GrCommentThread[],
    lineInfo: LineInfo,
    side: Side
  ) {
    function matchesLeftLine(threadEl: GrCommentThread) {
      return (
        threadEl.getAttribute('comment-side') === Side.LEFT &&
        threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
      );
    }
    function matchesRightLine(threadEl: GrCommentThread) {
      return (
        threadEl.getAttribute('comment-side') === Side.RIGHT &&
        threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
      );
    }
    function matchesFileComment(threadEl: GrCommentThread) {
      return (
        threadEl.getAttribute('comment-side') === side &&
        // line/range comments have 1-based line set, if line is falsy it's
        // a file comment
        !threadEl.getAttribute('line-num')
      );
    }

    // Select the appropriate matchers for the desired side and line
    // If side is BOTH, we want both the left and right matcher.
    const matchers: ((thread: GrCommentThread) => boolean)[] = [];
    if (side !== Side.RIGHT) {
      matchers.push(matchesLeftLine);
    }
    if (side !== Side.LEFT) {
      matchers.push(matchesRightLine);
    }
    if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
      matchers.push(matchesFileComment);
    }
    return threadEls.filter(threadEl =>
      matchers.some(matcher => matcher(threadEl))
    );
  }

  _getIgnoreWhitespace(): IgnoreWhitespaceType {
    if (!this.prefs || !this.prefs.ignore_whitespace) {
      return IgnoreWhitespaceType.IGNORE_NONE;
    }
    return this.prefs.ignore_whitespace;
  }

  @observe(
    'prefs.ignore_whitespace',
    '_loadedWhitespaceLevel',
    'noRenderOnPrefsChange'
  )
  _whitespaceChanged(
    preferredWhitespaceLevel?: IgnoreWhitespaceType,
    loadedWhitespaceLevel?: IgnoreWhitespaceType,
    noRenderOnPrefsChange?: boolean
  ) {
    if (preferredWhitespaceLevel === undefined) return;
    if (loadedWhitespaceLevel === undefined) return;
    if (noRenderOnPrefsChange === undefined) return;

    this._fetchDiffPromise = null;
    if (
      preferredWhitespaceLevel !== loadedWhitespaceLevel &&
      !noRenderOnPrefsChange
    ) {
      this.reload();
    }
  }

  @observe('noRenderOnPrefsChange', 'prefs.*')
  _syntaxHighlightingChanged(
    noRenderOnPrefsChange?: boolean,
    prefsChangeRecord?: PolymerDeepPropertyChange<
      DiffPreferencesInfo,
      DiffPreferencesInfo
    >
  ) {
    if (noRenderOnPrefsChange === undefined) return;
    if (prefsChangeRecord === undefined) return;
    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;

    if (!noRenderOnPrefsChange) this.reload();
  }

  _computeParentIndex(
    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
  ) {
    if (!patchRangeRecord.base) return null;
    return isMergeParent(patchRangeRecord.base.basePatchNum)
      ? getParentIndex(patchRangeRecord.base.basePatchNum)
      : null;
  }

  _handleCommentSave(e: CustomEvent) {
    const comment = e.detail.comment;
    const side = e.detail.comment.__commentSide;
    const idx = this._findDraftIndex(comment, side);
    this.set(['comments', side, idx], comment);
    this._handleCommentSaveOrDiscard();
  }

  _handleCommentDiscard(e: CustomEvent) {
    const comment = e.detail.comment;
    this._removeComment(comment);
    this._handleCommentSaveOrDiscard();
  }

  _handleCommentUpdate(e: CustomEvent) {
    const comment = e.detail.comment;
    const side = e.detail.comment.__commentSide;
    let idx = this._findCommentIndex(comment, side);
    if (idx === -1) {
      idx = this._findDraftIndex(comment, side);
    }
    if (idx !== -1) {
      // Update draft or comment.
      this.set(['comments', side, idx], comment);
    } else {
      // Create new draft.
      this.push(['comments', side], comment);
    }
  }

  _handleCommentSaveOrDiscard() {
    this.dispatchEvent(
      new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
    );
  }

  _removeComment(comment: UIComment) {
    const side = comment.__commentSide;
    if (!side) throw new Error('Missing required "side" in comment.');
    this._removeCommentFromSide(comment, side);
  }

  _removeCommentFromSide(comment: Comment, side: Side) {
    let idx = this._findCommentIndex(comment, side);
    if (idx === -1) {
      idx = this._findDraftIndex(comment, side);
    }
    if (idx !== -1) {
      this.splice('comments.' + side, idx, 1);
    }
  }

  _findCommentIndex(comment: Comment, side: Side) {
    if (!comment.id || !this.comments || !this.comments[side]) {
      return -1;
    }
    return this.comments[side].findIndex(item => item.id === comment.id);
  }

  _findDraftIndex(comment: Comment, side: Side) {
    if (
      !isDraft(comment) ||
      !comment.__draftID ||
      !this.comments ||
      !this.comments[side]
    ) {
      return -1;
    }
    return this.comments[side].findIndex(
      item => isDraft(item) && item.__draftID === comment.__draftID
    );
  }

  _isSyntaxHighlightingEnabled(
    preferenceChangeRecord?: PolymerDeepPropertyChange<
      DiffPreferencesInfo,
      DiffPreferencesInfo
    >,
    diff?: DiffInfo
  ) {
    if (
      !preferenceChangeRecord ||
      !preferenceChangeRecord.base ||
      !preferenceChangeRecord.base.syntax_highlighting ||
      !diff
    ) {
      return false;
    }
    return (
      !this._anyLineTooLong(diff) &&
      this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
    );
  }

  /**
   * @return whether any of the lines in diff are longer
   * than SYNTAX_MAX_LINE_LENGTH.
   */
  _anyLineTooLong(diff?: DiffInfo) {
    if (!diff) return false;
    return diff.content.some(section => {
      const lines = section.ab
        ? section.ab
        : (section.a || []).concat(section.b || []);
      return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
    });
  }

  _listenToViewportRender() {
    const renderUpdateListener: DiffLayerListener = start => {
      if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
        this.reporting.diffViewDisplayed();
        this.$.syntaxLayer.removeListener(renderUpdateListener);
      }
    };

    this.$.syntaxLayer.addListener(renderUpdateListener);
  }

  _handleRenderStart() {
    this.reporting.time(TimingLabel.TOTAL);
    this.reporting.time(TimingLabel.CONTENT);
  }

  _handleRenderContent() {
    this.reporting.timeEnd(TimingLabel.CONTENT);
  }

  _handleNormalizeRange(event: CustomEvent) {
    this.reporting.reportInteraction('normalize-range', {
      side: event.detail.side,
      lineNum: event.detail.lineNum,
    });
  }

  _handleDiffContextExpanded(event: CustomEvent) {
    this.reporting.reportInteraction('diff-context-expanded', {
      numLines: event.detail.numLines,
    });
  }

  /**
   * Find the last chunk for the given side.
   *
   * @param leftSide true if checking the base of the diff,
   * false if testing the revision.
   * @return returns the chunk object or null if there was
   * no chunk for that side.
   */
  _lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
    if (!diff?.content.length) {
      return null;
    }

    let chunkIndex = diff.content.length;
    let chunk;

    // Walk backwards until we find a chunk for the given side.
    do {
      chunkIndex--;
      chunk = diff.content[chunkIndex];
    } while (
      // We haven't reached the beginning.
      chunkIndex >= 0 &&
      // The chunk doesn't have both sides.
      !chunk.ab &&
      // The chunk doesn't have the given side.
      ((leftSide && (!chunk.a || !chunk.a.length)) ||
        (!leftSide && (!chunk.b || !chunk.b.length)))
    );

    // If we reached the beginning of the diff and failed to find a chunk
    // with the given side, return null.
    if (chunkIndex === -1) {
      return null;
    }

    return chunk;
  }

  /**
   * Check whether the specified side of the diff has a trailing newline.
   *
   * @param leftSide true if checking the base of the diff,
   * false if testing the revision.
   * @return Return true if the side has a trailing newline.
   * Return false if it doesn't. Return null if not applicable (for
   * example, if the diff has no content on the specified side).
   */
  _hasTrailingNewlines(diff: DiffInfo | undefined, leftSide: boolean) {
    const chunk = this._lastChunkForSide(diff, leftSide);
    if (!chunk) return null;
    let lines;
    if (chunk.ab) {
      lines = chunk.ab;
    } else {
      lines = leftSide ? chunk.a : chunk.b;
    }
    if (!lines) return null;
    return lines[lines.length - 1] === '';
  }

  _showNewlineWarningLeft(diff?: DiffInfo) {
    return this._hasTrailingNewlines(diff, true) === false;
  }

  _showNewlineWarningRight(diff?: DiffInfo) {
    return this._hasTrailingNewlines(diff, false) === false;
  }

  _useNewContextControls() {
    return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-diff-host': GrDiffHost;
  }
}

// TODO(TS): Be more specific than CustomEvent, which has detail:any.
declare global {
  interface HTMLElementEventMap {
    render: CustomEvent;
    'normalize-range': CustomEvent;
    'diff-context-expanded': CustomEvent;
    'create-comment': CustomEvent;
    'comment-discard': CustomEvent;
    'comment-update': CustomEvent;
    'comment-save': CustomEvent;
    'root-id-changed': CustomEvent;
  }
}
