/**
 * @license
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import {
  MovedLinkClickedEventDetail,
  RenderPreferences,
} from '../../../api/diff';
import {fire} from '../../../utils/event-util';
import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
import '../gr-context-controls/gr-context-controls';
import {
  GrContextControls,
  GrContextControlsShowConfig,
} from '../gr-context-controls/gr-context-controls';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {DiffViewMode, Side} from '../../../constants/constants';
import {DiffLayer} from '../../../types/types';
import {
  createBlameElement,
  createElementDiff,
  createElementDiffWithText,
  formatText,
  getResponsiveMode,
} from '../gr-diff/gr-diff-utils';
import {GrDiffBuilder} from './gr-diff-builder';
import {BlameInfo} from '../../../types/common';

function lineTdSelector(lineNumber: LineNumber, side?: Side): string {
  const sideSelector = side ? `.${side}` : '';
  return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`;
}
/**
 * Base class for builders that are creating the DOM elements programmatically
 * by calling `document.createElement()` and such. We are calling such builders
 * "legacy", because we want to create (Lit) component based diff elements.
 *
 * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
 */
export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
  constructor(
    diff: DiffInfo,
    prefs: DiffPreferencesInfo,
    outputEl: HTMLElement,
    layers: DiffLayer[] = [],
    renderPrefs?: RenderPreferences
  ) {
    super(diff, prefs, outputEl, layers, renderPrefs);
  }

  override getContentTdByLine(
    lineNumber: LineNumber,
    side?: Side,
    root: Element = this.outputEl
  ): HTMLTableCellElement | null {
    return root.querySelector<HTMLTableCellElement>(
      `${lineTdSelector(lineNumber, side)} ~ td.content`
    );
  }

  override getLineElByNumber(
    lineNumber: LineNumber,
    side?: Side
  ): HTMLTableCellElement | null {
    return this.outputEl.querySelector<HTMLTableCellElement>(
      lineTdSelector(lineNumber, side)
    );
  }

  override getLineNumberRows() {
    return Array.from(
      this.outputEl.querySelectorAll<HTMLTableRowElement>(
        ':not(.contextControl) > .diff-row'
      ) ?? []
    ).filter(tr => tr.querySelector('button'));
  }

  override getLineNumEls(side: Side): HTMLTableCellElement[] {
    return Array.from(
      this.outputEl.querySelectorAll<HTMLTableCellElement>(
        `td.lineNum.${side}`
      ) ?? []
    );
  }

  override getBlameTdByLine(lineNum: number): Element | undefined {
    return (
      this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
      undefined
    );
  }

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

  override 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);
      const newContent = this.createTextEl(lineNumberEl, line, side)
        .firstChild as HTMLElement;
      // Note that ${el.id} ${newContent.id} might actually mismatch: In unified
      // diff we are rendering the same content twice for all the diff chunk
      // that are unchanged from left to right. TODO: Be smarter about this.
      el.parentElement.replaceChild(newContent, el);
    }
  }

  override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
    for (let i = start; i <= 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 blameCell = this.getBlameTdByLine(i);
      if (!blameCell) continue;

      // Remove the element's children (if any).
      while (blameCell.hasChildNodes()) {
        blameCell.removeChild(blameCell.lastChild!);
      }
      const blameEl = createBlameElement(i, blame);
      if (blameEl) blameCell.appendChild(blameEl);
    }
  }

  /**
   * 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.
   */
  // visible for testing
  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;
  }

  /**
   * Adds <tr> table rows to a <tbody> section for allowing the user to expand
   * collapsed of lines. Called by subclasses.
   */
  protected createContextControls(
    section: HTMLElement,
    group: GrDiffGroup,
    viewMode: DiffViewMode
  ) {
    const leftStart = group.lineRange.left.start_line;
    const leftEnd = group.lineRange.left.end_line;
    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
    const lastGroupIsSkipped =
      !!group.contextGroups[group.contextGroups.length - 1].skip;

    const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
    const showAbove =
      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
    const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;

    if (showAbove) {
      const paddingRow = this.createContextControlPaddingRow(viewMode);
      paddingRow.classList.add('above');
      section.appendChild(paddingRow);
    }
    section.appendChild(
      this.createContextControlRow(group, showAbove, showBelow, viewMode)
    );
    if (showBelow) {
      const paddingRow = this.createContextControlPaddingRow(viewMode);
      paddingRow.classList.add('below');
      section.appendChild(paddingRow);
    }
  }

  /**
   * Creates a context control <tr> table row for with buttons the allow the
   * user to expand collapsed lines. Buttons extend from the gap created by this
   * method up or down into the area of code that they affect.
   */
  private createContextControlRow(
    group: GrDiffGroup,
    showAbove: boolean,
    showBelow: boolean,
    viewMode: DiffViewMode
  ): HTMLElement {
    const row = createElementDiff('tr', 'dividerRow');
    let showConfig: GrContextControlsShowConfig;
    if (showAbove && !showBelow) {
      showConfig = 'above';
    } else if (!showAbove && showBelow) {
      showConfig = 'below';
    } else {
      // Note that !showAbove && !showBelow also intentionally creates
      // "show-both". This means the file is completely collapsed, which is
      // unusual, but at least happens in one test.
      showConfig = 'both';
    }
    row.classList.add(`show-${showConfig}`);

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

    const cell = createElementDiff('td', 'dividerCell');
    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
    cell.setAttribute('colspan', colspan);
    row.appendChild(cell);

    const contextControls = createElementDiff(
      'gr-context-controls'
    ) as GrContextControls;
    contextControls.diff = this._diff;
    contextControls.renderPreferences = this.renderPrefs;
    contextControls.group = group;
    contextControls.showConfig = showConfig;
    cell.appendChild(contextControls);
    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.
   */
  private createContextControlPaddingRow(viewMode: DiffViewMode) {
    const row = createElementDiff('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(createElementDiff('td', 'contextLineNum'));
    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
      row.appendChild(createElementDiff('td', 'sign'));
      row.appendChild(createElementDiff('td'));
    }
    row.appendChild(createElementDiff('td', 'contextLineNum'));
    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
      row.appendChild(createElementDiff('td', 'sign'));
    }
    row.appendChild(createElementDiff('td'));

    return row;
  }

  protected createLineEl(
    line: GrDiffLine,
    number: LineNumber,
    type: GrDiffLineType,
    side: Side
  ) {
    const td = createElementDiff('td');
    td.classList.add(side);
    if (line.type === GrDiffLineType.BLANK) {
      td.classList.add('blankLineNum');
      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 = createElementDiff('button');
      td.appendChild(button);
      button.tabIndex = -1;
      button.classList.add('lineNumButton');
      button.classList.add(side);
      button.dataset['value'] = number.toString();
      button.id =
        side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`;
      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`);
        } else {
          button.setAttribute('aria-label', `${number} unmodified`);
        }
      }
      this.addLineNumberMouseEvents(td, number, side);
    }
    return td;
  }

  private addLineNumberMouseEvents(
    el: HTMLElement,
    number: LineNumber,
    side: Side
  ) {
    el.addEventListener('mouseenter', () => {
      fire(el, 'line-mouse-enter', {lineNum: number, side});
    });
    el.addEventListener('mouseleave', () => {
      fire(el, 'line-mouse-leave', {lineNum: number, side});
    });
  }

  // visible for testing
  createTextEl(
    lineNumberEl: HTMLElement | null,
    line: GrDiffLine,
    side?: Side
  ) {
    const td = createElementDiff('td');
    if (line.type !== GrDiffLineType.BLANK) {
      td.classList.add('content');
    }
    if (side) {
      td.classList.add(side);
    }

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

    const {beforeNumber, afterNumber} = line;
    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
      const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
      const contentText = formatText(
        line.text,
        responsiveMode,
        this._prefs.tab_size,
        this._prefs.line_length,
        side === Side.LEFT
          ? `left-content-${beforeNumber}`
          : `right-content-${afterNumber}`
      );

      if (side) {
        contentText.setAttribute('data-side', side);
        const number = side === Side.LEFT ? beforeNumber : afterNumber;
        this.addLineNumberMouseEvents(td, number, side);
      }

      if (lineNumberEl && side) {
        for (const layer of this.layers) {
          if (typeof layer.annotate === 'function') {
            layer.annotate(contentText, lineNumberEl, line, side);
          }
        }
      } else {
        console.error('lineNumberEl or side not set, skipping layer.annotate');
      }

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

    return td;
  }

  private createMovedLineAnchor(line: number, side: Side) {
    const anchor = createElementDiffWithText('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;
  }

  private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
    const div = createElementDiff('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(createElementDiffWithText('span', textLabel));
      div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
      div.appendChild(createElementDiffWithText('span', ' - '));
      div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
    } else {
      div.appendChild(
        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
      );
    }
    return div;
  }

  protected buildMoveControls(group: GrDiffGroup) {
    const movedIn = group.adds.length > 0;
    const {
      numberOfCells,
      movedOutIndex,
      movedInIndex,
      lineNumberCols,
      signCols,
    } = 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 = createElementDiff('tr', `moveControls ${controlsClass}`);
    const cells = [...Array(numberOfCells).keys()].map(() =>
      createElementDiff('td')
    );
    lineNumberCols.forEach(index => {
      cells[index].classList.add('moveControlsLineNumCol');
    });

    if (signCols) {
      cells[signCols.left].classList.add('sign', 'left');
      cells[signCols.right].classList.add('sign', 'right');
    }
    const moveRangeHeader = createElementDiff('gr-range-header');
    moveRangeHeader.setAttribute('icon', 'move_item');
    moveRangeHeader.appendChild(descriptionTextDiv);
    cells[descriptionIndex].classList.add('moveHeader');
    cells[descriptionIndex].appendChild(moveRangeHeader);
    cells.forEach(c => {
      controls.appendChild(c);
    });
    return controls;
  }

  /**
   * Create a blame cell for the given base line. Blame information will be
   * included in the cell if available.
   */
  // visible for testing
  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
    const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
    blameTd.setAttribute('data-line-number', lineNumber.toString());
    if (!lineNumber) return blameTd;

    const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
    if (!blameInfo) return blameTd;

    blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
    return blameTd;
  }
}
