/**
 * @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.
 */
import {
  ContentLoadNeededEventDetail,
  MovedLinkClickedEventDetail,
  RenderPreferences,
} from '../../../api/diff';
import {getBaseUrl} from '../../../utils/url-util';
import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
import {
  GrDiffGroup,
  GrDiffGroupType,
  hideInContextControl,
} from '../gr-diff/gr-diff-group';
import {BlameInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {DiffViewMode, Side} from '../../../constants/constants';
import {DiffLayer} from '../../../types/types';
import {pluralize} from '../../../utils/string-util';
import {fire} from '../../../utils/event-util';

/**
 * In JS, unicode code points above 0xFFFF occupy two elements of a string.
 * For example '𐀏'.length is 2. An occurrence of such a code point is called a
 * surrogate pair.
 *
 * This regex segments a string along tabs ('\t') and surrogate pairs, since
 * these are two cases where '1 char' does not automatically imply '1 column'.
 *
 * TODO: For human languages whose orthographies use combining marks, this
 * approach won't correctly identify the grapheme boundaries. In those cases,
 * a grapheme consists of multiple code points that should count as only one
 * character against the column limit. Getting that correct (if it's desired)
 * is probably beyond the limits of a regex, but there are nonstandard APIs to
 * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
 *
 * Further reading:
 *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
 *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
 *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
 */
const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;

const PARTIAL_CONTEXT_AMOUNT = 10;

enum ContextButtonType {
  ABOVE = 'above',
  BELOW = 'below',
  ALL = 'all',
}

export interface DiffContextExpandedEventDetail {
  groups: GrDiffGroup[];
  section: HTMLElement;
  numLines: number;
}

declare global {
  interface HTMLElementEventMap {
    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
  }
}

export abstract class GrDiffBuilder {
  private readonly _diff: DiffInfo;

  private readonly _numLinesLeft: number;

  private readonly _prefs: DiffPreferencesInfo;

  private readonly _renderPrefs?: RenderPreferences;

  protected readonly _outputEl: HTMLElement;

  readonly groups: GrDiffGroup[];

  private blameInfo: BlameInfo[] | null;

  private readonly _layerUpdateListener: (
    start: LineNumber,
    end: LineNumber,
    side: Side
  ) => void;

  constructor(
    diff: DiffInfo,
    prefs: DiffPreferencesInfo,
    outputEl: HTMLElement,
    readonly layers: DiffLayer[] = [],
    renderPrefs?: RenderPreferences
  ) {
    this._diff = diff;
    this._numLinesLeft = this._diff.content
      ? this._diff.content.reduce((sum, chunk) => {
          const left = chunk.a || chunk.ab;
          return sum + (left?.length || chunk.skip || 0);
        }, 0)
      : 0;
    this._prefs = prefs;
    this._renderPrefs = renderPrefs;
    this._outputEl = outputEl;
    this.groups = [];
    this.blameInfo = null;

    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
      throw Error('Invalid tab size from preferences.');
    }

    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
      throw Error('Invalid line length from preferences.');
    }

    this._layerUpdateListener = (
      start: LineNumber,
      end: LineNumber,
      side: Side
    ) => this._handleLayerUpdate(start, end, side);
    for (const layer of this.layers) {
      if (layer.addListener) {
        layer.addListener(this._layerUpdateListener);
      }
    }
  }

  clear() {
    for (const layer of this.layers) {
      if (layer.removeListener) {
        layer.removeListener(this._layerUpdateListener);
      }
    }
  }

  // TODO(TS): Convert to enum.
  static readonly GroupType = {
    ADDED: 'b',
    BOTH: 'ab',
    REMOVED: 'a',
  };

  // TODO(TS): Convert to enum.
  static readonly Highlights = {
    ADDED: 'edit_b',
    REMOVED: 'edit_a',
  };

  // TODO(TS): Replace usages with ContextButtonType enum.
  static readonly ContextButtonType = {
    ABOVE: 'above',
    BELOW: 'below',
    ALL: 'all',
  };

  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;

  abstract buildSectionElement(group: GrDiffGroup): HTMLElement;

  emitGroup(group: GrDiffGroup, beforeSection: HTMLElement | null) {
    const element = this.buildSectionElement(group);
    this._outputEl.insertBefore(element, beforeSection);
    group.element = element;
  }

  getGroupsByLineRange(
    startLine: LineNumber,
    endLine: LineNumber,
    side?: Side
  ) {
    const groups = [];
    for (let i = 0; i < this.groups.length; i++) {
      const group = this.groups[i];
      if (group.lines.length === 0) {
        continue;
      }
      let groupStartLine = 0;
      let groupEndLine = 0;
      if (side) {
        const range =
          side === Side.LEFT ? group.lineRange.left : group.lineRange.right;
        groupStartLine = range.start_line;
        groupEndLine = range.end_line;
      }

      if (groupStartLine === 0) {
        // Line was removed or added.
        groupStartLine = groupEndLine;
      }
      if (groupEndLine === 0) {
        // Line was removed or added.
        groupEndLine = groupStartLine;
      }
      if (startLine <= groupEndLine && endLine >= groupStartLine) {
        groups.push(group);
      }
    }
    return groups;
  }

  getContentTdByLine(
    lineNumber: LineNumber,
    side?: Side,
    root: Element = this._outputEl
  ): Element | null {
    const sideSelector: string = side ? `.${side}` : '';
    return root.querySelector(
      `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
    );
  }

  getContentByLine(
    lineNumber: LineNumber,
    side?: Side,
    root?: HTMLElement
  ): HTMLElement | null {
    const td = this.getContentTdByLine(lineNumber, side, root);
    return td ? td.querySelector('.contentText') : null;
  }

  /**
   * Find line elements or line objects by a range of line numbers and a side.
   *
   * @param start The first line number
   * @param end The last line number
   * @param side The side of the range. Either 'left' or 'right'.
   * @param out_lines The output list of line objects. Use null if not desired.
   * @param out_elements The output list of line elements. Use null if not
   *        desired.
   */
  findLinesByRange(
    start: LineNumber,
    end: LineNumber,
    side: Side,
    out_lines: GrDiffLine[] | null,
    out_elements: HTMLElement[] | null
  ) {
    const groups = this.getGroupsByLineRange(start, end, side);
    for (const group of groups) {
      let content: HTMLElement | null = null;
      for (const line of group.lines) {
        if (
          (side === 'left' && line.type === GrDiffLineType.ADD) ||
          (side === 'right' && line.type === GrDiffLineType.REMOVE)
        ) {
          continue;
        }
        const lineNumber =
          side === 'left' ? line.beforeNumber : line.afterNumber;
        if (lineNumber < start || lineNumber > end) {
          continue;
        }

        if (out_lines) {
          out_lines.push(line);
        }
        if (out_elements) {
          if (content) {
            content = this._getNextContentOnSide(content, side);
          } else {
            content = this.getContentByLine(lineNumber, side, group.element);
          }
          if (content) {
            out_elements.push(content);
          }
        }
      }
    }
  }

  /**
   * Re-renders the DIV.contentText elements for the given side and range of
   * diff content.
   */
  _renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
    const lines: GrDiffLine[] = [];
    const elements: HTMLElement[] = [];
    let line;
    let el;
    this.findLinesByRange(start, end, side, lines, elements);
    for (let i = 0; i < lines.length; i++) {
      line = lines[i];
      el = elements[i];
      if (!el || !el.parentElement) {
        // Cannot re-render an element if it does not exist. This can happen
        // if lines are collapsed and not visible on the page yet.
        continue;
      }
      const lineNumberEl = this._getLineNumberEl(el, side);
      el.parentElement.replaceChild(
        this._createTextEl(lineNumberEl, line, side).firstChild!,
        el
      );
    }
  }

  getSectionsByLineRange(
    startLine: LineNumber,
    endLine: LineNumber,
    side: Side
  ) {
    return this.getGroupsByLineRange(startLine, endLine, side).map(
      group => group.element
    );
  }

  _createContextControls(
    section: HTMLElement,
    contextGroups: GrDiffGroup[],
    viewMode: DiffViewMode
  ) {
    const leftStart = contextGroups[0].lineRange.left.start_line;
    const leftEnd =
      contextGroups[contextGroups.length - 1].lineRange.left.end_line;
    const numLines = leftEnd - leftStart + 1;

    if (numLines === 0) console.error('context group without lines');

    const firstGroupIsSkipped = !!contextGroups[0].skip;
    const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;

    const showAbove = leftStart > 1 && !firstGroupIsSkipped;
    const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;

    if (showAbove) {
      const paddingRow = this._createContextControlPaddingRow(viewMode);
      paddingRow.classList.add('above');
      section.appendChild(paddingRow);
    }
    section.appendChild(
      this._createContextControlRow(
        section,
        contextGroups,
        showAbove,
        showBelow,
        numLines,
        viewMode
      )
    );
    if (showBelow) {
      const paddingRow = this._createContextControlPaddingRow(viewMode);
      paddingRow.classList.add('below');
      section.appendChild(paddingRow);
    }
  }

  /**
   * Creates context controls. Buttons extend from the gap created by this
   * method up or down into the area of code that they affect.
   */
  _createContextControlRow(
    section: HTMLElement,
    contextGroups: GrDiffGroup[],
    showAbove: boolean,
    showBelow: boolean,
    numLines: number,
    viewMode: DiffViewMode
  ): HTMLElement {
    const row = this._createElement('tr', 'dividerRow');
    if (showAbove && !showBelow) {
      row.classList.add('showAboveOnly');
    } else if (!showAbove && showBelow) {
      row.classList.add('showBelowOnly');
    } else {
      // Note that !showAbove && !showBelow also intentionally creates
      // "showBoth". This means the file is completely collapsed, which is
      // unusual, but at least happens in one test.
      row.classList.add('showBoth');
    }

    row.appendChild(this._createBlameCell(0));
    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
      row.appendChild(this._createElement('td'));
    }

    const cell = this._createElement('td', 'dividerCell');
    cell.setAttribute('colspan', '3');
    row.appendChild(cell);
    const verticalFlex = this._createElement('div', 'verticalFlex');
    cell.appendChild(verticalFlex);
    const horizontalFlex = this._createElement('div', 'horizontalFlex');
    verticalFlex.appendChild(horizontalFlex);

    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
    horizontalFlex.appendChild(showAllContainer);
    const showAllButton = this._createContextButton(
      ContextButtonType.ALL,
      section,
      contextGroups,
      numLines
    );
    showAllButton.classList.add(
      showAbove && showBelow
        ? 'centeredButton'
        : showAbove
        ? 'aboveButton'
        : 'belowButton'
    );
    showAllContainer.appendChild(showAllButton);

    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
    if (showPartialLinks) {
      const container = this._createElement('div', 'aboveBelowButtons');
      if (showAbove) {
        container.appendChild(
          this._createContextButton(
            ContextButtonType.ABOVE,
            section,
            contextGroups,
            numLines
          )
        );
      }
      if (showBelow) {
        container.appendChild(
          this._createContextButton(
            ContextButtonType.BELOW,
            section,
            contextGroups,
            numLines
          )
        );
      }
      horizontalFlex.appendChild(container);
    }

    return row;
  }

  /**
   * Creates a table row to serve as padding between code and context controls.
   * Blame column, line gutters, and content area will continue visually, but
   * context controls can render over this background to map more clearly to
   * the area of code they expand.
   */
  _createContextControlPaddingRow(viewMode: DiffViewMode) {
    const row = this._createElement('tr', 'contextBackground');

    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
      row.classList.add('side-by-side');
      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
    } else {
      row.classList.add('unified');
    }

    row.appendChild(this._createBlameCell(0));
    row.appendChild(this._createElement('td', 'contextLineNum'));
    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
      row.appendChild(this._createElement('td'));
    }
    row.appendChild(this._createElement('td', 'contextLineNum'));
    row.appendChild(this._createElement('td'));

    return row;
  }

  _createContextButton(
    type: ContextButtonType,
    section: HTMLElement,
    contextGroups: GrDiffGroup[],
    numLines: number
  ) {
    const context = PARTIAL_CONTEXT_AMOUNT;
    const button = this._createElement('gr-button', 'showContext');
    button.classList.add('contextControlButton');
    button.setAttribute('link', 'true');
    button.setAttribute('no-uppercase', 'true');

    let text = '';
    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
    let requiresLoad = false;
    if (type === GrDiffBuilder.ContextButtonType.ALL) {
      text = `+${pluralize(numLines, 'common line')}`;
      button.setAttribute(
        'aria-label',
        `Show ${pluralize(numLines, 'common line')}`
      );
      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
      if (requiresLoad) {
        // Expanding content would require load of more data
        text += ' (too large)';
      }
      groups.push(...contextGroups);
    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
      groups = hideInContextControl(contextGroups, context, numLines);
      text = `+${context}`;
      button.classList.add('aboveButton');
      button.setAttribute(
        'aria-label',
        `Show ${pluralize(context, 'line')} above`
      );
    } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
      groups = hideInContextControl(contextGroups, 0, numLines - context);
      text = `+${context}`;
      button.classList.add('belowButton');
      button.setAttribute(
        'aria-label',
        `Show ${pluralize(context, 'line')} below`
      );
    }
    const textSpan = this._createElement('span', 'showContext');
    textSpan.textContent = text;
    button.appendChild(textSpan);

    if (requiresLoad) {
      button.addEventListener('click', e => {
        e.stopPropagation();
        const firstRange = groups[0].lineRange;
        const lastRange = groups[groups.length - 1].lineRange;
        const lineRange = {
          left: {
            start_line: firstRange.left.start_line,
            end_line: lastRange.left.end_line,
          },
          right: {
            start_line: firstRange.right.start_line,
            end_line: lastRange.right.end_line,
          },
        };
        fire(button, 'content-load-needed', {
          lineRange,
        });
      });
    } else {
      button.addEventListener('click', e => {
        e.stopPropagation();
        fire(button, 'diff-context-expanded', {
          groups,
          section,
          numLines,
        });
      });
    }

    return button;
  }

  _createLineEl(
    line: GrDiffLine,
    number: LineNumber,
    type: GrDiffLineType,
    side: Side
  ) {
    const td = this._createElement('td');
    td.classList.add(side);
    if (line.type === GrDiffLineType.BLANK) {
      return td;
    }
    if (line.type === GrDiffLineType.BOTH || line.type === type) {
      td.classList.add('lineNum');
      td.dataset['value'] = number.toString();

      if (
        ((this._prefs.show_file_comment_button === false ||
          this._renderPrefs?.show_file_comment_button === false) &&
          number === 'FILE') ||
        number === 'LOST'
      ) {
        return td;
      }

      const button = this._createElement('button');
      td.appendChild(button);
      button.tabIndex = -1;
      button.classList.add('lineNumButton');
      button.classList.add(side);
      button.dataset['value'] = number.toString();
      button.textContent = number === 'FILE' ? 'File' : number.toString();
      if (number === 'FILE') {
        button.setAttribute('aria-label', 'Add file comment');
      }

      // Add aria-labels for valid line numbers.
      // For unified diff, this method will be called with number set to 0 for
      // the empty line number column for added/removed lines. This should not
      // be announced to the screenreader.
      if (number > 0) {
        if (line.type === GrDiffLineType.REMOVE) {
          button.setAttribute('aria-label', `${number} removed`);
        } else if (line.type === GrDiffLineType.ADD) {
          button.setAttribute('aria-label', `${number} added`);
        }
      }
    }

    return td;
  }

  _createTextEl(
    lineNumberEl: HTMLElement | null,
    line: GrDiffLine,
    side?: Side
  ) {
    const td = this._createElement('td');
    if (line.type !== GrDiffLineType.BLANK) {
      td.classList.add('content');
    }

    // If intraline info is not available, the entire line will be
    // considered as changed and marked as dark red / green color
    if (!line.hasIntralineInfo) {
      td.classList.add('no-intraline-info');
    }
    td.classList.add(line.type);

    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
      const lineLimit = !this._prefs.line_wrapping
        ? this._prefs.line_length
        : Infinity;
      const contentText = this._formatText(
        line.text,
        this._prefs.tab_size,
        lineLimit
      );

      if (side) {
        contentText.setAttribute('data-side', side);
      }

      if (lineNumberEl) {
        for (const layer of this.layers) {
          if (typeof layer.annotate === 'function') {
            layer.annotate(contentText, lineNumberEl, line);
          }
        }
      } else {
        console.error('The lineNumberEl is null, skipping layer annotations.');
      }

      td.appendChild(contentText);
    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
    else if (line.beforeNumber === 'LOST') td.classList.add('lost');

    return td;
  }

  /**
   * Returns a 'div' element containing the supplied |text| as its innerText,
   * with '\t' characters expanded to a width determined by |tabSize|, and the
   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
   * desired.
   *
   * @param text The text to be formatted.
   * @param tabSize The width of each tab stop.
   * @param lineLimit The column after which to wrap lines.
   */
  _formatText(text: string, tabSize: number, lineLimit: number): HTMLElement {
    const contentText = this._createElement('div', 'contentText');

    let columnPos = 0;
    let textOffset = 0;
    for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
      if (segment) {
        // |segment| contains only normal characters. If |segment| doesn't fit
        // entirely on the current line, append chunks of |segment| followed by
        // line breaks.
        let rowStart = 0;
        let rowEnd = lineLimit - columnPos;
        while (rowEnd < segment.length) {
          contentText.appendChild(
            document.createTextNode(segment.substring(rowStart, rowEnd))
          );
          contentText.appendChild(this._createElement('span', 'br'));
          columnPos = 0;
          rowStart = rowEnd;
          rowEnd += lineLimit;
        }
        // Append the last part of |segment|, which fits on the current line.
        contentText.appendChild(
          document.createTextNode(segment.substring(rowStart))
        );
        columnPos += segment.length - rowStart;
        textOffset += segment.length;
      }
      if (textOffset < text.length) {
        // Handle the special character at |textOffset|.
        if (text.startsWith('\t', textOffset)) {
          // Append a single '\t' character.
          let effectiveTabSize = tabSize - (columnPos % tabSize);
          if (columnPos + effectiveTabSize > lineLimit) {
            contentText.appendChild(this._createElement('span', 'br'));
            columnPos = 0;
            effectiveTabSize = tabSize;
          }
          contentText.appendChild(this._getTabWrapper(effectiveTabSize));
          columnPos += effectiveTabSize;
          textOffset++;
        } else {
          // Append a single surrogate pair.
          if (columnPos >= lineLimit) {
            contentText.appendChild(this._createElement('span', 'br'));
            columnPos = 0;
          }
          contentText.appendChild(
            document.createTextNode(text.substring(textOffset, textOffset + 2))
          );
          textOffset += 2;
          columnPos += 1;
        }
      }
    }
    return contentText;
  }

  /**
   * Returns a <span> element holding a '\t' character, that will visually
   * occupy |tabSize| many columns.
   *
   * @param tabSize The effective size of this tab stop.
   */
  _getTabWrapper(tabSize: number): HTMLElement {
    // Force this to be a number to prevent arbitrary injection.
    const result = this._createElement('span', 'tab');
    result.setAttribute(
      'style',
      `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
    );
    result.innerText = '\t';
    return result;
  }

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

  _handleLayerUpdate(start: LineNumber, end: LineNumber, side: Side) {
    this._renderContentByRange(start, end, side);
  }

  /**
   * Finds the next DIV.contentText element following the given element, and on
   * the same side. Will only search within a group.
   */
  abstract _getNextContentOnSide(
    content: HTMLElement,
    side: Side
  ): HTMLElement | null;

  /**
   * Gets configuration for creating move controls for chunks marked with
   * dueToMove
   */
  abstract _getMoveControlsConfig(): {
    numberOfCells: number;
    movedOutIndex: number;
    movedInIndex: number;
  };

  /**
   * Determines whether the given group is either totally an addition or totally
   * a removal.
   */
  _isTotal(group: GrDiffGroup): boolean {
    return (
      group.type === GrDiffGroupType.DELTA &&
      (!group.adds.length || !group.removes.length) &&
      !(!group.adds.length && !group.removes.length)
    );
  }

  /**
   * Set the blame information for the diff. For any already-rendered line,
   * re-render its blame cell content.
   */
  setBlame(blame: BlameInfo[] | null) {
    this.blameInfo = blame;
    if (!blame) return;

    // TODO(wyatta): make this loop asynchronous.
    for (const commit of blame) {
      for (const range of commit.ranges) {
        for (let i = range.start; i <= range.end; i++) {
          // TODO(wyatta): this query is expensive, but, when traversing a
          // range, the lines are consecutive, and given the previous blame
          // cell, the next one can be reached cheaply.
          const el = this._getBlameByLineNum(i);
          if (!el) {
            continue;
          }
          // Remove the element's children (if any).
          while (el.hasChildNodes()) {
            el.removeChild(el.lastChild!);
          }
          const blame = this._getBlameForBaseLine(i, commit);
          if (blame) el.appendChild(blame);
        }
      }
    }
  }

  _createMovedLineAnchor(line: number, side: Side) {
    const anchor = this._createElementWithText('a', `${line}`);

    // href is not actually used but important for Screen Readers
    anchor.setAttribute('href', `#${line}`);
    anchor.addEventListener('click', e => {
      e.preventDefault();
      anchor.dispatchEvent(
        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
          detail: {
            lineNum: line,
            side,
          },
          composed: true,
          bubbles: true,
        })
      );
    });
    return anchor;
  }

  _createElementWithText(tagName: string, textContent: string) {
    const element = this._createElement(tagName);
    element.textContent = textContent;
    return element;
  }

  _createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
    const div = this._createElement('div');
    if (group.moveDetails?.range) {
      const {changed, range} = group.moveDetails;
      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
      const andChangedLabel = changed ? 'and changed ' : '';
      const direction = movedIn ? 'from' : 'to';
      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
      div.appendChild(this._createElementWithText('span', textLabel));
      div.appendChild(this._createMovedLineAnchor(range.start, otherSide));
      div.appendChild(this._createElementWithText('span', ' - '));
      div.appendChild(this._createMovedLineAnchor(range.end, otherSide));
    } else {
      div.appendChild(
        this._createElementWithText('span', movedIn ? 'Moved in' : 'Moved out')
      );
    }
    return div;
  }

  _buildMoveControls(group: GrDiffGroup) {
    const movedIn = group.adds.length > 0;
    const {
      numberOfCells,
      movedOutIndex,
      movedInIndex,
    } = this._getMoveControlsConfig();

    let controlsClass;
    let descriptionIndex;
    const descriptionTextDiv = this._createMoveDescriptionDiv(movedIn, group);
    if (movedIn) {
      controlsClass = 'movedIn';
      descriptionIndex = movedInIndex;
    } else {
      controlsClass = 'movedOut';
      descriptionIndex = movedOutIndex;
    }

    const controls = this._createElement('tr', `moveControls ${controlsClass}`);
    const cells = [...Array(numberOfCells).keys()].map(() =>
      this._createElement('td')
    );
    const moveDescriptionDiv = this._createElement('div', 'moveDescription');
    const icon = this._createElement('iron-icon');
    icon.setAttribute('icon', 'gr-icons:move-item');
    moveDescriptionDiv.appendChild(icon);
    moveDescriptionDiv.appendChild(descriptionTextDiv);
    cells[descriptionIndex].appendChild(moveDescriptionDiv);
    cells[descriptionIndex].classList.add('moveLabel');
    cells.forEach(c => {
      controls.appendChild(c);
    });
    return controls;
  }

  /**
   * Find the blame cell for a given line number.
   */
  _getBlameByLineNum(lineNum: number): Element | null {
    return this._outputEl.querySelector(
      `td.blame[data-line-number="${lineNum}"]`
    );
  }

  /**
   * Given a base line number, return the commit containing that line in the
   * current set of blame information. If no blame information has been
   * provided, null is returned.
   *
   * @return The commit information.
   */
  _getBlameCommitForBaseLine(lineNum: LineNumber) {
    if (!this.blameInfo) {
      return null;
    }

    for (const blameCommit of this.blameInfo) {
      for (const range of blameCommit.ranges) {
        if (range.start <= lineNum && range.end >= lineNum) {
          return blameCommit;
        }
      }
    }
    return null;
  }

  /**
   * Given the number of a base line, get the content for the blame cell of that
   * line. If there is no blame information for that line, returns null.
   *
   * @param commit Optionally provide the commit object, so that
   *     it does not need to be searched.
   */
  _getBlameForBaseLine(
    lineNum: LineNumber,
    commit: BlameInfo | null = this._getBlameCommitForBaseLine(lineNum)
  ): HTMLElement | null {
    if (!commit) {
      return null;
    }

    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);

    const date = new Date(commit.time * 1000).toLocaleDateString();
    const blameNode = this._createElement(
      'span',
      isStartOfRange ? 'startOfRange' : ''
    );

    const shaNode = this._createElement('a', 'blameDate');
    shaNode.innerText = `${date}`;
    shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
    blameNode.appendChild(shaNode);

    const shortName = commit.author.split(' ')[0];
    const authorNode = this._createElement('span', 'blameAuthor');
    authorNode.innerText = ` ${shortName}`;
    blameNode.appendChild(authorNode);

    const hoverCardFragment = this._createElement('span', 'blameHoverCard');
    hoverCardFragment.innerText = `Commit ${commit.id}
Author: ${commit.author}
Date: ${date}

${commit.commit_msg}`;
    const hovercard = this._createElement('gr-hovercard');
    hovercard.appendChild(hoverCardFragment);
    blameNode.appendChild(hovercard);

    return blameNode;
  }

  /**
   * Create a blame cell for the given base line. Blame information will be
   * included in the cell if available.
   */
  _createBlameCell(lineNumber: LineNumber): HTMLTableDataCellElement {
    const blameTd = this._createElement(
      'td',
      'blame'
    ) as HTMLTableDataCellElement;
    blameTd.setAttribute('data-line-number', lineNumber.toString());
    if (lineNumber) {
      const content = this._getBlameForBaseLine(lineNumber);
      if (content) {
        blameTd.appendChild(content);
      }
    }
    return blameTd;
  }

  /**
   * Finds the line number element given the content element by walking up the
   * DOM tree to the diff row and then querying for a .lineNum element on the
   * requested side.
   *
   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
   */
  _getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
    let row: HTMLElement | null = content;
    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
  }
}
