/**
 * Copyright (c) 2018 The xterm.js authors. All rights reserved.
 * @license MIT
 */

import { DomRendererRowFactory, RowCss } from './DomRendererRowFactory';
import { WidthCache } from './WidthCache';
import { INVERTED_DEFAULT_COLOR, RendererConstants } from '../shared/Constants';
import { createRenderDimensions } from '../shared/RendererUtils';
import { createSelectionRenderModel } from '../shared/SelectionRenderModel';
import { TextBlinkStateManager } from '../shared/TextBlinkStateManager';
import { IRenderDimensions, IRenderer, IRequestRedrawEvent, ISelectionRenderModel } from '../shared/Types';
import { ICharSizeService, ICoreBrowserService, IThemeService } from '../../services/Services';
import { ILinkifier2, ILinkifierEvent, ITerminal, ReadonlyColorSet } from '../../Types';
import { color } from '../../../common/Color';
import { Disposable, toDisposable } from '../../../common/Lifecycle';
import { IBufferService, ICoreService, IInstantiationService, IOptionsService } from '../../../common/services/Services';
import { Emitter } from '../../../common/Event';
import { addDisposableListener } from '../../Dom';


const enum Constants {
  TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-',
  ROW_CONTAINER_CLASS = 'xterm-rows',
  FG_CLASS_PREFIX = 'xterm-fg-',
  BG_CLASS_PREFIX = 'xterm-bg-',
  FOCUS_CLASS = 'xterm-focus',
  SELECTION_CLASS = 'xterm-selection',
  CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle'
}

let nextTerminalId = 1;

/**
 * The standard renderer and fallback for when the webgl addon is slow. This is not meant to be
 * particularly fast and will even lack some features such as custom glyphs, hoever this is more
 * reliable as webgl may not work on some machines.
 */
export class DomRenderer extends Disposable implements IRenderer {
  private _rowFactory: DomRendererRowFactory;
  private _terminalClass: number = nextTerminalId++;

  private _themeStyleElement!: HTMLStyleElement;
  private _dimensionsStyleElement!: HTMLStyleElement;
  private _rowContainer: HTMLElement;
  private _rowElements: HTMLElement[] = [];
  private _selectionContainer: HTMLElement;
  private _widthCache: WidthCache;
  private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
  private _lastSelectionStart: [number, number] | undefined;
  private _lastSelectionEnd: [number, number] | undefined;
  private _lastSelectionColumnMode: boolean = false;
  private _cursorBlinkStateManager: CursorBlinkStateManager;
  private _textBlinkStateManager: TextBlinkStateManager;
  private _rowHasBlinkingCells: boolean[] = [];
  private _rowHasBlinkingCellsCount: number = 0;

  public dimensions: IRenderDimensions;

  private readonly _onRequestRedraw = this._register(new Emitter<IRequestRedrawEvent>());
  public readonly onRequestRedraw = this._onRequestRedraw.event;

  constructor(
    private readonly _terminal: ITerminal,
    private readonly _document: Document,
    private readonly _element: HTMLElement,
    private readonly _screenElement: HTMLElement,
    private readonly _viewportElement: HTMLElement,
    private readonly _helperContainer: HTMLElement,
    private readonly _linkifier2: ILinkifier2,
    @IInstantiationService instantiationService: IInstantiationService,
    @ICharSizeService private readonly _charSizeService: ICharSizeService,
    @IOptionsService private readonly _optionsService: IOptionsService,
    @IBufferService private readonly _bufferService: IBufferService,
    @ICoreService private readonly _coreService: ICoreService,
    @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
    @IThemeService private readonly _themeService: IThemeService
  ) {
    super();
    this._rowContainer = this._document.createElement('div');
    this._rowContainer.classList.add(Constants.ROW_CONTAINER_CLASS);
    this._rowContainer.style.lineHeight = 'normal';
    this._rowContainer.setAttribute('aria-hidden', 'true');
    this._refreshRowElements(this._bufferService.cols, this._bufferService.rows);
    this._selectionContainer = this._document.createElement('div');
    this._selectionContainer.classList.add(Constants.SELECTION_CLASS);
    this._selectionContainer.setAttribute('aria-hidden', 'true');

    this.dimensions = createRenderDimensions();
    this._updateDimensions();
    this._register(this._optionsService.onOptionChange(() => this._handleOptionsChanged()));

    this._register(this._themeService.onChangeColors(e => this._injectCss(e)));
    this._injectCss(this._themeService.colors);

    this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document);

    this._element.classList.add(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
    this._screenElement.appendChild(this._rowContainer);
    this._screenElement.appendChild(this._selectionContainer);

    this._register(this._linkifier2.onShowLinkUnderline(e => this._handleLinkHover(e)));
    this._register(this._linkifier2.onHideLinkUnderline(e => this._handleLinkLeave(e)));

    this._cursorBlinkStateManager = new CursorBlinkStateManager(this._rowContainer, this._coreBrowserService);
    this._register(addDisposableListener(this._document, 'mousedown', () => this._cursorBlinkStateManager.restartBlinkAnimation()));
    this._register(toDisposable(() => this._cursorBlinkStateManager.dispose()));
    this._textBlinkStateManager = this._register(new TextBlinkStateManager(
      () => this._onRequestRedraw.fire({ start: 0, end: this._bufferService.rows - 1 }),
      this._coreBrowserService,
      this._optionsService
    ));

    this._register(toDisposable(() => {
      this._element.classList.remove(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);

      // Outside influences such as React unmounts may manipulate the DOM before our disposal.
      // https://github.com/xtermjs/xterm.js/issues/2960
      this._rowContainer.remove();
      this._selectionContainer.remove();
      this._widthCache.dispose();
      this._themeStyleElement.remove();
      this._dimensionsStyleElement.remove();
    }));

    this._widthCache = new WidthCache();
    this._widthCache.setFont(
      this._optionsService.rawOptions.fontFamily,
      this._optionsService.rawOptions.fontSize,
      this._optionsService.rawOptions.fontWeight,
      this._optionsService.rawOptions.fontWeightBold
    );
    this._setDefaultSpacing();
  }

  private _updateDimensions(): void {
    const dpr = this._coreBrowserService.dpr;
    this.dimensions.device.char.width = this._charSizeService.width * dpr;
    this.dimensions.device.char.height = Math.ceil(this._charSizeService.height * dpr);
    this.dimensions.device.cell.width = this.dimensions.device.char.width + Math.round(this._optionsService.rawOptions.letterSpacing);
    this.dimensions.device.cell.height = Math.floor(this.dimensions.device.char.height * this._optionsService.rawOptions.lineHeight);
    this.dimensions.device.char.left = 0;
    this.dimensions.device.char.top = 0;
    this.dimensions.device.canvas.width = this.dimensions.device.cell.width * this._bufferService.cols;
    this.dimensions.device.canvas.height = this.dimensions.device.cell.height * this._bufferService.rows;
    this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / dpr);
    this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / dpr);
    this.dimensions.css.cell.width = this.dimensions.css.canvas.width / this._bufferService.cols;
    this.dimensions.css.cell.height = this.dimensions.css.canvas.height / this._bufferService.rows;

    for (const element of this._rowElements) {
      element.style.width = `${this.dimensions.css.canvas.width}px`;
      element.style.height = `${this.dimensions.css.cell.height}px`;
      element.style.lineHeight = `${this.dimensions.css.cell.height}px`;
      // Make sure rows don't overflow onto following row
      element.style.overflow = 'hidden';
    }

    if (!this._dimensionsStyleElement) {
      this._dimensionsStyleElement = this._document.createElement('style');
      this._screenElement.appendChild(this._dimensionsStyleElement);
    }

    const styles =
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
      ` display: inline-block;` +   // TODO: find workaround for inline-block (creates ~20% render penalty)
      ` height: 100%;` +
      ` vertical-align: top;` +
      `}`;

    this._dimensionsStyleElement.textContent = styles;

    this._selectionContainer.style.height = this._viewportElement.style.height;
    this._screenElement.style.width = `${this.dimensions.css.canvas.width}px`;
    this._screenElement.style.height = `${this.dimensions.css.canvas.height}px`;
  }

  private _injectCss(colors: ReadonlyColorSet): void {
    if (!this._themeStyleElement) {
      this._themeStyleElement = this._document.createElement('style');
      this._screenElement.appendChild(this._themeStyleElement);
    }

    // Base CSS
    let styles =
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} {` +
      // Disabling pointer events circumvents a browser behavior that prevents `click` events from
      // being delivered if the target element is replaced during the click. This happened due to
      // refresh() being called during the mousedown handler to start a selection.
      ` pointer-events: none;` +
      ` color: ${colors.foreground.css};` +
      `}`;
    styles +=
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
      ` font-family: ${this._optionsService.rawOptions.fontFamily};` +
      ` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
      ` font-kerning: none;` +
      ` white-space: pre` +
      `}`;
    styles +=
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .xterm-dim {` +
      ` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` +
      `}`;
    // Text styles
    styles +=
      `${this._terminalSelector} span:not(.${RowCss.BOLD_CLASS}) {` +
      ` font-weight: ${this._optionsService.rawOptions.fontWeight};` +
      `}` +
      `${this._terminalSelector} span.${RowCss.BOLD_CLASS} {` +
      ` font-weight: ${this._optionsService.rawOptions.fontWeightBold};` +
      `}` +
      `${this._terminalSelector} span.${RowCss.ITALIC_CLASS} {` +
      ` font-style: italic;` +
      `}` +
      `${this._terminalSelector} span.${RowCss.BLINK_HIDDEN_CLASS} {` +
      ` visibility: hidden;` +
      `}`;
    // Blink animation
    const blinkAnimationUnderlineId = `blink_underline_${this._terminalClass}`;
    const blinkAnimationBarId = `blink_bar_${this._terminalClass}`;
    const blinkAnimationBlockId = `blink_block_${this._terminalClass}`;
    styles +=
      `@keyframes ${blinkAnimationUnderlineId} {` +
      ` 50% {` +
      `  border-bottom-style: hidden;` +
      ` }` +
      `}`;
    styles +=
      `@keyframes ${blinkAnimationBarId} {` +
      ` 50% {` +
      `  box-shadow: none;` +
      ` }` +
      `}`;
    styles +=
      `@keyframes ${blinkAnimationBlockId} {` +
      ` 0% {` +
      `  background-color: ${colors.cursor.css};` +
      `  color: ${colors.cursorAccent.css};` +
      ` }` +
      ` 50% {` +
      `  background-color: inherit;` +
      `  color: ${colors.cursor.css};` +
      ` }` +
      `}`;
    // Cursor
    styles +=
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
      ` animation: ${blinkAnimationUnderlineId} 1s step-end infinite;` +
      `}` +
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
      ` animation: ${blinkAnimationBarId} 1s step-end infinite;` +
      `}` +
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
      ` animation: ${blinkAnimationBlockId} 1s step-end infinite;` +
      `}` +
      // Disable cursor blinking when idle
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
      ` animation: none !important;` +
      `}` +
      // !important helps fix an issue where the cursor will not render on top of the selection,
      // however it's very hard to fix this issue and retain the blink animation without the use of
      // !important. So this edge case fails when cursor blink is on.
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
      ` background-color: ${colors.cursor.css};` +
      ` color: ${colors.cursorAccent.css};` +
      `}` +
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
      ` background-color: ${colors.cursor.css} !important;` +
      ` color: ${colors.cursorAccent.css} !important;` +
      `}` +
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
      ` outline: 1px solid ${colors.cursor.css};` +
      ` outline-offset: -1px;` +
      `}` +
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
      ` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
      `}` +
      `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
      ` border-bottom: 1px ${colors.cursor.css};` +
      ` border-bottom-style: solid;` +
      ` height: calc(100% - 1px);` +
      `}`;
    // Selection
    styles +=
      `${this._terminalSelector} .${Constants.SELECTION_CLASS} {` +
      ` position: absolute;` +
      ` top: 0;` +
      ` left: 0;` +
      ` z-index: 1;` +
      ` pointer-events: none;` +
      `}` +
      `${this._terminalSelector}.focus .${Constants.SELECTION_CLASS} div {` +
      ` position: absolute;` +
      ` background-color: ${colors.selectionBackgroundOpaque.css};` +
      `}` +
      `${this._terminalSelector} .${Constants.SELECTION_CLASS} div {` +
      ` position: absolute;` +
      ` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` +
      `}`;
    // Colors
    for (const [i, c] of colors.ansi.entries()) {
      styles +=
        `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
        `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
        `${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
    }
    styles +=
      `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
      `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
      `${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;

    this._themeStyleElement.textContent = styles;
  }

  /**
   * default letter spacing
   * Due to rounding issues in dimensions dpr calc glyph might render
   * slightly too wide or too narrow. The method corrects the stacking offsets
   * by applying a default letter-spacing for all chars.
   * The value gets passed to the row factory to avoid setting this value again
   * (render speedup is roughly 10%).
   */
  private _setDefaultSpacing(): void {
    // measure same char as in CharSizeService to get the base deviation
    const spacing = this.dimensions.css.cell.width - this._widthCache.get('W', false, false);
    this._rowContainer.style.letterSpacing = `${spacing}px`;
    this._rowFactory.defaultSpacing = spacing;
  }

  public handleDevicePixelRatioChange(): void {
    this._updateDimensions();
    this._widthCache.clear();
    this._setDefaultSpacing();
  }

  private _refreshRowElements(cols: number, rows: number): void {
    // Add missing elements
    for (let i = this._rowElements.length; i <= rows; i++) {
      const row = this._document.createElement('div');
      this._rowContainer.appendChild(row);
      this._rowElements.push(row);
      this._rowHasBlinkingCells.push(false);
    }
    // Remove excess elements
    while (this._rowElements.length > rows) {
      this._rowContainer.removeChild(this._rowElements.pop()!);
      if (this._rowHasBlinkingCells.pop()) {
        this._rowHasBlinkingCellsCount--;
      }
    }
  }

  public handleResize(cols: number, rows: number): void {
    this._refreshRowElements(cols, rows);
    this._updateDimensions();
    this.handleSelectionChanged(this._selectionRenderModel.selectionStart, this._selectionRenderModel.selectionEnd, this._selectionRenderModel.columnSelectMode);
  }

  public handleCharSizeChanged(): void {
    this._updateDimensions();
    this._widthCache.clear();
    this._setDefaultSpacing();
  }

  public handleBlur(): void {
    this._rowContainer.classList.remove(Constants.FOCUS_CLASS);
    this._cursorBlinkStateManager.pause();
    this.renderRows(0, this._bufferService.rows - 1);
  }

  public handleFocus(): void {
    this._rowContainer.classList.add(Constants.FOCUS_CLASS);
    this._cursorBlinkStateManager.resume();
    this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
  }

  public handleViewportVisibilityChange(isVisible: boolean): void {
    this._textBlinkStateManager.setViewportVisible(isVisible);
  }

  public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
    const rows = this._bufferService.rows;

    // Remove all selections
    this._selectionContainer.replaceChildren();
    this._rowFactory.handleSelectionChanged(start, end, columnSelectMode);

    // Determine old selection viewport band
    let oldViewportStart = 0;
    let oldViewportEnd = -1;
    if (this._lastSelectionStart && this._lastSelectionEnd) {
      this._selectionRenderModel.update(this._terminal, this._lastSelectionStart, this._lastSelectionEnd, this._lastSelectionColumnMode);
      if (this._selectionRenderModel.hasSelection) {
        oldViewportStart = this._selectionRenderModel.viewportCappedStartRow;
        oldViewportEnd = this._selectionRenderModel.viewportCappedEndRow;
      }
    }

    // Determine new selection viewport band and create overlays
    let newViewportStart = 0;
    let newViewportEnd = -1;
    if (!start || !end) {
      return;
    }
    this._selectionRenderModel.update(this._terminal, start, end, columnSelectMode);
    if (this._selectionRenderModel.hasSelection) {
      const viewportStartRow = this._selectionRenderModel.viewportStartRow;
      const viewportEndRow = this._selectionRenderModel.viewportEndRow;
      const viewportCappedStartRow = this._selectionRenderModel.viewportCappedStartRow;
      const viewportCappedEndRow = this._selectionRenderModel.viewportCappedEndRow;

      newViewportStart = viewportCappedStartRow;
      newViewportEnd = viewportCappedEndRow;

      // Create the selections
      const documentFragment = this._document.createDocumentFragment();

      if (columnSelectMode) {
        const isXFlipped = start[0] > end[0];
        documentFragment.appendChild(
          this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1)
        );
      } else {
        // Draw first row
        const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
        const endCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
        documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
        // Draw middle rows
        const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
        documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._bufferService.cols, middleRowsCount));
        // Draw final row
        if (viewportCappedStartRow !== viewportCappedEndRow) {
          // Only draw viewportEndRow if it's not the same as viewporttartRow
          const finalEndCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
          documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, finalEndCol));
        }
      }
      this._selectionContainer.appendChild(documentFragment);
    }

    // Compute minimal row range to redraw
    let renderStartRow = Math.min(oldViewportStart, newViewportStart);
    let renderEndRow = Math.max(oldViewportEnd, newViewportEnd);

    if (renderEndRow >= 0) {
      // Clamp to viewport
      renderStartRow = Math.max(renderStartRow, 0);
      renderEndRow = Math.min(renderEndRow, rows - 1);

      // Ensure cursor row is included when a selection is present
      const buffer = this._bufferService.buffer;
      const cursorViewportRow = buffer.y;
      if (this._selectionRenderModel.hasSelection && cursorViewportRow >= 0 && cursorViewportRow < rows) {
        renderStartRow = Math.min(renderStartRow, cursorViewportRow);
        renderEndRow = Math.max(renderEndRow, cursorViewportRow);
      }

      this.renderRows(renderStartRow, renderEndRow);
    }

    // Update last selection state
    this._lastSelectionStart = start;
    this._lastSelectionEnd = end;
    this._lastSelectionColumnMode = columnSelectMode;
  }

  /**
   * Creates a selection element at the specified position.
   * @param row The row of the selection.
   * @param colStart The start column.
   * @param colEnd The end columns.
   */
  private _createSelectionElement(row: number, colStart: number, colEnd: number, rowCount: number = 1): HTMLElement {
    const element = this._document.createElement('div');
    const left = colStart * this.dimensions.css.cell.width;
    let width = this.dimensions.css.cell.width * (colEnd - colStart);
    if (left + width > this.dimensions.css.canvas.width) {
      width = this.dimensions.css.canvas.width - left;
    }

    element.style.height = `${rowCount * this.dimensions.css.cell.height}px`;
    element.style.top = `${row * this.dimensions.css.cell.height}px`;
    element.style.left = `${left}px`;
    element.style.width = `${width}px`;
    return element;
  }

  public handleCursorMove(): void {
    // Reset idle timer on cursor movement (which happens on input)
    this._cursorBlinkStateManager.restartBlinkAnimation();
  }

  private _handleOptionsChanged(): void {
    // Force a refresh
    this._updateDimensions();
    // Refresh CSS
    this._injectCss(this._themeService.colors);
    // update spacing cache
    this._widthCache.setFont(
      this._optionsService.rawOptions.fontFamily,
      this._optionsService.rawOptions.fontSize,
      this._optionsService.rawOptions.fontWeight,
      this._optionsService.rawOptions.fontWeightBold
    );
    this._setDefaultSpacing();
  }

  public clear(): void {
    for (const e of this._rowElements) {
      /**
       * NOTE: This used to be `e.innerText = '';` but that doesn't work when using `jsdom` and
       * `@testing-library/react`
       *
       * references:
       * - https://github.com/testing-library/react-testing-library/issues/1146
       * - https://github.com/jsdom/jsdom/issues/1245
       */
      e.replaceChildren();
    }
    if (this._rowHasBlinkingCellsCount > 0) {
      this._rowHasBlinkingCells.fill(false);
      this._rowHasBlinkingCellsCount = 0;
      this._textBlinkStateManager.setNeedsBlinkInViewport(false);
    }
  }

  public renderRows(start: number, end: number): void {
    const buffer = this._bufferService.buffer;
    const cursorAbsoluteY = buffer.ybase + buffer.y;
    const cursorX = Math.min(buffer.x, this._bufferService.cols - 1);
    const cursorBlink = this._coreService.decPrivateModes.cursorBlink ?? this._optionsService.rawOptions.cursorBlink;
    const cursorStyle = this._coreService.decPrivateModes.cursorStyle ?? this._optionsService.rawOptions.cursorStyle;
    const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
    const rowInfo = { hasBlinkingCells: false };

    for (let y = start; y <= end; y++) {
      const row = y + buffer.ydisp;
      const rowElement = this._rowElements[y];
      if (!rowElement) {
        continue;
      }
      const lineData = buffer.lines.get(row);
      if (!lineData) {
        rowElement.replaceChildren();
        this._setRowBlinkState(y, false);
        continue;
      }
      rowElement.replaceChildren(
        ...this._rowFactory.createRow(
          lineData,
          row,
          row === cursorAbsoluteY,
          cursorStyle,
          cursorInactiveStyle,
          cursorX,
          cursorBlink,
          this._textBlinkStateManager.isBlinkOn,
          this.dimensions.css.cell.width,
          this._widthCache,
          -1,
          -1,
          rowInfo
        )
      );
      this._setRowBlinkState(y, rowInfo.hasBlinkingCells);
    }
    this._updateTextBlinkState();
  }

  private get _terminalSelector(): string {
    return `.${Constants.TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
  }

  private _handleLinkHover(e: ILinkifierEvent): void {
    this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, true);
  }

  private _handleLinkLeave(e: ILinkifierEvent): void {
    this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, false);
  }

  private _setCellUnderline(x: number, x2: number, y: number, y2: number, cols: number, enabled: boolean): void {
    /**
     * NOTE: The linkifier may send out of viewport y-values if:
     * - negative y-value: the link started at a higher line
     * - y-value >= maxY: the link ends at a line below viewport
     *
     * For negative y-values we can simply adjust x = 0,
     * as higher up link start means, that everything from
     * (0,0) is a link under top-down-left-right char progression
     *
     * Additionally there might be a small chance of out-of-sync x|y-values
     * from a race condition of render updates vs. link event handler execution:
     * - (sync) resize: chances terminal buffer in sync, schedules render update async
     * - (async) link handler race condition: new buffer metrics, but still on old render state
     * - (async) render update: brings term metrics and render state back in sync
     */
    // clip coords into viewport
    if (y < 0) x = 0;
    if (y2 < 0) x2 = 0;
    const maxY = this._bufferService.rows - 1;
    y = Math.max(Math.min(y, maxY), 0);
    y2 = Math.max(Math.min(y2, maxY), 0);

    cols = Math.min(cols, this._bufferService.cols);
    const buffer = this._bufferService.buffer;
    const cursorAbsoluteY = buffer.ybase + buffer.y;
    const cursorX = Math.min(buffer.x, cols - 1);
    const cursorBlink = this._optionsService.rawOptions.cursorBlink;
    const cursorStyle = this._optionsService.rawOptions.cursorStyle;
    const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
    const rowInfo = { hasBlinkingCells: false };

    // refresh rows within link range
    for (let i = y; i <= y2; ++i) {
      const row = i + buffer.ydisp;
      const rowElement = this._rowElements[i];
      if (!rowElement) {
        continue;
      }
      const bufferline = buffer.lines.get(row);
      if (!bufferline) {
        rowElement.replaceChildren();
        this._setRowBlinkState(i, false);
        continue;
      }
      rowElement.replaceChildren(
        ...this._rowFactory.createRow(
          bufferline,
          row,
          row === cursorAbsoluteY,
          cursorStyle,
          cursorInactiveStyle,
          cursorX,
          cursorBlink,
          this._textBlinkStateManager.isBlinkOn,
          this.dimensions.css.cell.width,
          this._widthCache,
          enabled ? (i === y ? x : 0) : -1,
          enabled ? ((i === y2 ? x2 : cols) - 1) : -1,
          rowInfo
        )
      );
      this._setRowBlinkState(i, rowInfo.hasBlinkingCells);
    }
    this._updateTextBlinkState();
  }

  private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
    const previous = this._rowHasBlinkingCells[row];
    if (previous === hasBlinkingCells) {
      return;
    }
    this._rowHasBlinkingCells[row] = hasBlinkingCells;
    this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
  }

  private _updateTextBlinkState(): void {
    this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
  }
}

class CursorBlinkStateManager {
  private _idleTimeout: number | undefined;
  private _isIdlePaused: boolean = false;

  constructor(
    private readonly _rowContainer: HTMLElement,
    private readonly _coreBrowserService: ICoreBrowserService
  ) {
    if (this._coreBrowserService.isFocused) {
      this._resetIdleTimer();
    }
  }

  public dispose(): void {
    this._clearIdleTimer();
  }

  public restartBlinkAnimation(): void {
    if (this._isIdlePaused) {
      this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
    }
    this._resetIdleTimer();
  }

  public pause(): void {
    this._isIdlePaused = false;
    this._clearIdleTimer();
  }

  public resume(): void {
    this._isIdlePaused = false;
    this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
    this._resetIdleTimer();
  }

  private _resetIdleTimer(): void {
    this._isIdlePaused = false;
    this._clearIdleTimer();
    this._idleTimeout = this._coreBrowserService.window.setTimeout(() => {
      this._stopBlinkingDueToIdle();
    }, RendererConstants.CURSOR_BLINK_IDLE_TIMEOUT);
  }

  private _clearIdleTimer(): void {
    if (this._idleTimeout !== undefined) {
      this._coreBrowserService.window.clearTimeout(this._idleTimeout);
      this._idleTimeout = undefined;
    }
  }

  private _stopBlinkingDueToIdle(): void {
    this._rowContainer.classList.add(Constants.CURSOR_BLINK_IDLE_CLASS);
    this._isIdlePaused = true;
    this._idleTimeout = undefined;
  }
}
