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

import { IRenderDimensions } from 'browser/renderer/Types';
import { IRenderLayer } from './Types';
import { ICellData, IColor } from 'common/Types';
import { DEFAULT_COLOR, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, Attributes } from 'common/buffer/Constants';
import { IGlyphIdentifier } from './atlas/Types';
import { DIM_OPACITY, INVERTED_DEFAULT_COLOR, TEXT_BASELINE } from 'browser/renderer/Constants';
import { BaseCharAtlas } from './atlas/BaseCharAtlas';
import { acquireCharAtlas } from './atlas/CharAtlasCache';
import { AttributeData } from 'common/buffer/AttributeData';
import { IColorSet } from 'browser/Types';
import { CellData } from 'common/buffer/CellData';
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
import { excludeFromContrastRatioDemands, throwIfFalsy } from 'browser/renderer/RendererUtils';
import { channels, color, rgba } from 'common/Color';
import { removeElementFromParent } from 'browser/Dom';
import { tryDrawCustomChar } from 'browser/renderer/CustomGlyphs';

export abstract class BaseRenderLayer implements IRenderLayer {
  private _canvas: HTMLCanvasElement;
  protected _ctx!: CanvasRenderingContext2D;
  private _scaledCharWidth: number = 0;
  private _scaledCharHeight: number = 0;
  private _scaledCellWidth: number = 0;
  private _scaledCellHeight: number = 0;
  private _scaledCharLeft: number = 0;
  private _scaledCharTop: number = 0;

  protected _selectionStart: [number, number] | undefined;
  protected _selectionEnd: [number, number] | undefined;
  protected _columnSelectMode: boolean = false;

  protected _charAtlas: BaseCharAtlas | undefined;

  /**
   * An object that's reused when drawing glyphs in order to reduce GC.
   */
  private _currentGlyphIdentifier: IGlyphIdentifier = {
    chars: '',
    code: 0,
    bg: 0,
    fg: 0,
    bold: false,
    dim: false,
    italic: false
  };

  public get canvas(): HTMLCanvasElement { return this._canvas; }

  constructor(
    private _container: HTMLElement,
    id: string,
    zIndex: number,
    private _alpha: boolean,
    protected _colors: IColorSet,
    private _rendererId: number,
    protected readonly _bufferService: IBufferService,
    protected readonly _optionsService: IOptionsService,
    protected readonly _decorationService: IDecorationService
  ) {
    this._canvas = document.createElement('canvas');
    this._canvas.classList.add(`xterm-${id}-layer`);
    this._canvas.style.zIndex = zIndex.toString();
    this._initCanvas();
    this._container.appendChild(this._canvas);
  }

  public dispose(): void {
    removeElementFromParent(this._canvas);
    this._charAtlas?.dispose();
  }

  private _initCanvas(): void {
    this._ctx = throwIfFalsy(this._canvas.getContext('2d', { alpha: this._alpha }));
    // Draw the background if this is an opaque layer
    if (!this._alpha) {
      this._clearAll();
    }
  }

  public onOptionsChanged(): void {}
  public onBlur(): void {}
  public onFocus(): void {}
  public onCursorMove(): void {}
  public onGridChanged(startRow: number, endRow: number): void {}

  public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void {
    this._selectionStart = start;
    this._selectionEnd = end;
    this._columnSelectMode = columnSelectMode;
  }

  public setColors(colorSet: IColorSet): void {
    this._refreshCharAtlas(colorSet);
  }

  protected _setTransparency(alpha: boolean): void {
    // Do nothing when alpha doesn't change
    if (alpha === this._alpha) {
      return;
    }

    // Create new canvas and replace old one
    const oldCanvas = this._canvas;
    this._alpha = alpha;
    // Cloning preserves properties
    this._canvas = this._canvas.cloneNode() as HTMLCanvasElement;
    this._initCanvas();
    this._container.replaceChild(this._canvas, oldCanvas);

    // Regenerate char atlas and force a full redraw
    this._refreshCharAtlas(this._colors);
    this.onGridChanged(0, this._bufferService.rows - 1);
  }

  /**
   * Refreshes the char atlas, aquiring a new one if necessary.
   * @param colorSet The color set to use for the char atlas.
   */
  private _refreshCharAtlas(colorSet: IColorSet): void {
    if (this._scaledCharWidth <= 0 && this._scaledCharHeight <= 0) {
      return;
    }
    this._charAtlas = acquireCharAtlas(this._optionsService.rawOptions, this._rendererId, colorSet, this._scaledCharWidth, this._scaledCharHeight);
    this._charAtlas.warmUp();
  }

  public resize(dim: IRenderDimensions): void {
    this._scaledCellWidth = dim.scaledCellWidth;
    this._scaledCellHeight = dim.scaledCellHeight;
    this._scaledCharWidth = dim.scaledCharWidth;
    this._scaledCharHeight = dim.scaledCharHeight;
    this._scaledCharLeft = dim.scaledCharLeft;
    this._scaledCharTop = dim.scaledCharTop;
    this._canvas.width = dim.scaledCanvasWidth;
    this._canvas.height = dim.scaledCanvasHeight;
    this._canvas.style.width = `${dim.canvasWidth}px`;
    this._canvas.style.height = `${dim.canvasHeight}px`;

    // Draw the background if this is an opaque layer
    if (!this._alpha) {
      this._clearAll();
    }

    this._refreshCharAtlas(this._colors);
  }

  public abstract reset(): void;

  public clearTextureAtlas(): void {
    this._charAtlas?.clear();
  }

  /**
   * Fills 1+ cells completely. This uses the existing fillStyle on the context.
   * @param x The column to start at.
   * @param y The row to start at
   * @param width The number of columns to fill.
   * @param height The number of rows to fill.
   */
  protected _fillCells(x: number, y: number, width: number, height: number): void {
    this._ctx.fillRect(
      x * this._scaledCellWidth,
      y * this._scaledCellHeight,
      width * this._scaledCellWidth,
      height * this._scaledCellHeight);
  }

  /**
     * Fills a 1px line (2px on HDPI) at the middle of the cell. This uses the
     * existing fillStyle on the context.
     * @param x The column to fill.
     * @param y The row to fill.
     */
  protected _fillMiddleLineAtCells(x: number, y: number, width: number = 1): void {
    const cellOffset = Math.ceil(this._scaledCellHeight * 0.5);
    this._ctx.fillRect(
      x * this._scaledCellWidth,
      (y + 1) * this._scaledCellHeight - cellOffset - window.devicePixelRatio,
      width * this._scaledCellWidth,
      window.devicePixelRatio);
  }

  /**
   * Fills a 1px line (2px on HDPI) at the bottom of the cell. This uses the
   * existing fillStyle on the context.
   * @param x The column to fill.
   * @param y The row to fill.
   */
  protected _fillBottomLineAtCells(x: number, y: number, width: number = 1, pixelOffset: number = 0): void {
    this._ctx.fillRect(
      x * this._scaledCellWidth,
      (y + 1) * this._scaledCellHeight + pixelOffset - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */,
      width * this._scaledCellWidth,
      window.devicePixelRatio);
  }

  protected _curlyUnderlineAtCell(x: number, y: number, width: number = 1): void {
    this._ctx.save();
    this._ctx.beginPath();
    this._ctx.strokeStyle = this._ctx.fillStyle;
    this._ctx.lineWidth = window.devicePixelRatio;
    for (let xOffset = 0; xOffset < width; xOffset++) {
      const xLeft = (x + xOffset) * this._scaledCellWidth;
      const xMid = (x + xOffset + 0.5) * this._scaledCellWidth;
      const xRight = (x + xOffset + 1) * this._scaledCellWidth;
      const yMid = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1;
      const yMidBot = yMid - window.devicePixelRatio;
      const yMidTop = yMid + window.devicePixelRatio;
      this._ctx.moveTo(xLeft, yMid);
      this._ctx.bezierCurveTo(
        xLeft, yMidBot,
        xMid, yMidBot,
        xMid, yMid
      );
      this._ctx.bezierCurveTo(
        xMid, yMidTop,
        xRight, yMidTop,
        xRight, yMid
      );
    }
    this._ctx.stroke();
    this._ctx.restore();
  }

  protected _dottedUnderlineAtCell(x: number, y: number, width: number = 1): void {
    this._ctx.save();
    this._ctx.beginPath();
    this._ctx.strokeStyle = this._ctx.fillStyle;
    this._ctx.lineWidth = window.devicePixelRatio;
    this._ctx.setLineDash([window.devicePixelRatio * 2, window.devicePixelRatio]);
    const xLeft = x * this._scaledCellWidth;
    const yMid = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1;
    this._ctx.moveTo(xLeft, yMid);
    for (let xOffset = 0; xOffset < width; xOffset++) {
      // const xLeft = x * this._scaledCellWidth;
      const xRight = (x + width + xOffset) * this._scaledCellWidth;
      this._ctx.lineTo(xRight, yMid);
    }
    this._ctx.stroke();
    this._ctx.closePath();
    this._ctx.restore();
  }

  protected _dashedUnderlineAtCell(x: number, y: number, width: number = 1): void {
    this._ctx.save();
    this._ctx.beginPath();
    this._ctx.strokeStyle = this._ctx.fillStyle;
    this._ctx.lineWidth = window.devicePixelRatio;
    this._ctx.setLineDash([window.devicePixelRatio * 4, window.devicePixelRatio * 3]);
    const xLeft = x * this._scaledCellWidth;
    const xRight = (x + width) * this._scaledCellWidth;
    const yMid = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1;
    this._ctx.moveTo(xLeft, yMid);
    this._ctx.lineTo(xRight, yMid);
    this._ctx.stroke();
    this._ctx.closePath();
    this._ctx.restore();
  }

  /**
   * Fills a 1px line (2px on HDPI) at the left of the cell. This uses the
   * existing fillStyle on the context.
   * @param x The column to fill.
   * @param y The row to fill.
   */
  protected _fillLeftLineAtCell(x: number, y: number, width: number): void {
    this._ctx.fillRect(
      x * this._scaledCellWidth,
      y * this._scaledCellHeight,
      window.devicePixelRatio * width,
      this._scaledCellHeight);
  }

  /**
   * Strokes a 1px rectangle (2px on HDPI) around a cell. This uses the existing
   * strokeStyle on the context.
   * @param x The column to fill.
   * @param y The row to fill.
   */
  protected _strokeRectAtCell(x: number, y: number, width: number, height: number): void {
    this._ctx.lineWidth = window.devicePixelRatio;
    this._ctx.strokeRect(
      x * this._scaledCellWidth + window.devicePixelRatio / 2,
      y * this._scaledCellHeight + (window.devicePixelRatio / 2),
      width * this._scaledCellWidth - window.devicePixelRatio,
      (height * this._scaledCellHeight) - window.devicePixelRatio);
  }

  /**
   * Clears the entire canvas.
   */
  protected _clearAll(): void {
    if (this._alpha) {
      this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
    } else {
      this._ctx.fillStyle = this._colors.background.css;
      this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height);
    }
  }

  /**
   * Clears 1+ cells completely.
   * @param x The column to start at.
   * @param y The row to start at.
   * @param width The number of columns to clear.
   * @param height The number of rows to clear.
   */
  protected _clearCells(x: number, y: number, width: number, height: number): void {
    if (this._alpha) {
      this._ctx.clearRect(
        x * this._scaledCellWidth,
        y * this._scaledCellHeight,
        width * this._scaledCellWidth,
        height * this._scaledCellHeight);
    } else {
      this._ctx.fillStyle = this._colors.background.css;
      this._ctx.fillRect(
        x * this._scaledCellWidth,
        y * this._scaledCellHeight,
        width * this._scaledCellWidth,
        height * this._scaledCellHeight);
    }
  }

  /**
   * Draws a truecolor character at the cell. The character will be clipped to
   * ensure that it fits with the cell, including the cell to the right if it's
   * a wide character. This uses the existing fillStyle on the context.
   * @param cell The cell data for the character to draw.
   * @param x The column to draw at.
   * @param y The row to draw at.
   * @param color The color of the character.
   */
  protected _fillCharTrueColor(cell: CellData, x: number, y: number): void {
    this._ctx.font = this._getFont(false, false);
    this._ctx.textBaseline = TEXT_BASELINE;
    this._clipRow(y);

    // Draw custom characters if applicable
    let drawSuccess = false;
    if (this._optionsService.rawOptions.customGlyphs !== false) {
      drawSuccess = tryDrawCustomChar(this._ctx, cell.getChars(), x * this._scaledCellWidth, y * this._scaledCellHeight, this._scaledCellWidth, this._scaledCellHeight);
    }

    // Draw the character
    if (!drawSuccess) {
      this._ctx.fillText(
        cell.getChars(),
        x * this._scaledCellWidth + this._scaledCharLeft,
        y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight);
    }
  }

  /**
   * Draws one or more characters at a cell. If possible this will draw using
   * the character atlas to reduce draw time.
   * @param chars The character or characters.
   * @param code The character code.
   * @param width The width of the characters.
   * @param x The column to draw at.
   * @param y The row to draw at.
   * @param fg The foreground color, in the format stored within the attributes.
   * @param bg The background color, in the format stored within the attributes.
   * This is used to validate whether a cached image can be used.
   * @param bold Whether the text is bold.
   */
  protected _drawChars(cell: ICellData, x: number, y: number): void {
    const contrastColor = this._getContrastColor(cell, x, y);

    // skip cache right away if we draw in RGB
    // Note: to avoid bad runtime JoinedCellData will be skipped
    //       in the cache handler itself (atlasDidDraw == false) and
    //       fall through to uncached later down below
    if (contrastColor || cell.isFgRGB() || cell.isBgRGB()) {
      this._drawUncachedChars(cell, x, y, contrastColor);
      return;
    }

    let fg;
    let bg;
    if (cell.isInverse()) {
      fg = (cell.isBgDefault()) ? INVERTED_DEFAULT_COLOR : cell.getBgColor();
      bg = (cell.isFgDefault()) ? INVERTED_DEFAULT_COLOR : cell.getFgColor();
    } else {
      bg = (cell.isBgDefault()) ? DEFAULT_COLOR : cell.getBgColor();
      fg = (cell.isFgDefault()) ? DEFAULT_COLOR : cell.getFgColor();
    }

    const drawInBrightColor = this._optionsService.rawOptions.drawBoldTextInBrightColors && cell.isBold() && fg < 8;

    fg += drawInBrightColor ? 8 : 0;
    this._currentGlyphIdentifier.chars = cell.getChars() || WHITESPACE_CELL_CHAR;
    this._currentGlyphIdentifier.code = cell.getCode() || WHITESPACE_CELL_CODE;
    this._currentGlyphIdentifier.bg = bg;
    this._currentGlyphIdentifier.fg = fg;
    this._currentGlyphIdentifier.bold = !!cell.isBold();
    this._currentGlyphIdentifier.dim = !!cell.isDim();
    this._currentGlyphIdentifier.italic = !!cell.isItalic();

    // Don't try cache the glyph if it uses any decoration foreground/background override.
    let hasOverrides = false;
    for (const d of this._decorationService.getDecorationsAtCell(x, y)) {
      if (d.backgroundColorRGB || d.foregroundColorRGB) {
        hasOverrides = true;
        break;
      }
    }

    const atlasDidDraw = hasOverrides ? false : this._charAtlas?.draw(this._ctx, this._currentGlyphIdentifier, x * this._scaledCellWidth + this._scaledCharLeft, y * this._scaledCellHeight + this._scaledCharTop);

    if (!atlasDidDraw) {
      this._drawUncachedChars(cell, x, y);
    }
  }

  /**
   * Draws one or more characters at one or more cells. The character(s) will be
   * clipped to ensure that they fit with the cell(s), including the cell to the
   * right if the last character is a wide character.
   * @param chars The character.
   * @param width The width of the character.
   * @param fg The foreground color, in the format stored within the attributes.
   * @param x The column to draw at.
   * @param y The row to draw at.
   */
  private _drawUncachedChars(cell: ICellData, x: number, y: number, fgOverride?: IColor): void {
    this._ctx.save();
    this._ctx.font = this._getFont(!!cell.isBold(), !!cell.isItalic());
    this._ctx.textBaseline = TEXT_BASELINE;

    if (cell.isInverse()) {
      if (fgOverride) {
        this._ctx.fillStyle = fgOverride.css;
      } else if (cell.isBgDefault()) {
        this._ctx.fillStyle = color.opaque(this._colors.background).css;
      } else if (cell.isBgRGB()) {
        this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getBgColor()).join(',')})`;
      } else {
        let bg = cell.getBgColor();
        if (this._optionsService.rawOptions.drawBoldTextInBrightColors && cell.isBold() && bg < 8) {
          bg += 8;
        }
        this._ctx.fillStyle = this._colors.ansi[bg].css;
      }
    } else {
      if (fgOverride) {
        this._ctx.fillStyle = fgOverride.css;
      } else if (cell.isFgDefault()) {
        this._ctx.fillStyle = this._colors.foreground.css;
      } else if (cell.isFgRGB()) {
        this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`;
      } else {
        let fg = cell.getFgColor();
        if (this._optionsService.rawOptions.drawBoldTextInBrightColors && cell.isBold() && fg < 8) {
          fg += 8;
        }
        this._ctx.fillStyle = this._colors.ansi[fg].css;
      }
    }

    this._clipRow(y);

    // Apply alpha to dim the character
    if (cell.isDim()) {
      this._ctx.globalAlpha = DIM_OPACITY;
    }

    // Draw custom characters if applicable
    let drawSuccess = false;
    if (this._optionsService.rawOptions.customGlyphs !== false) {
      drawSuccess = tryDrawCustomChar(this._ctx, cell.getChars(), x * this._scaledCellWidth, y * this._scaledCellHeight, this._scaledCellWidth, this._scaledCellHeight);
    }

    // Draw the character
    if (!drawSuccess) {
      this._ctx.fillText(
        cell.getChars(),
        x * this._scaledCellWidth + this._scaledCharLeft,
        y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight);
    }

    this._ctx.restore();
  }


  /**
   * Clips a row to ensure no pixels will be drawn outside the cells in the row.
   * @param y The row to clip.
   */
  private _clipRow(y: number): void {
    this._ctx.beginPath();
    this._ctx.rect(
      0,
      y * this._scaledCellHeight,
      this._bufferService.cols * this._scaledCellWidth,
      this._scaledCellHeight);
    this._ctx.clip();
  }

  /**
   * Gets the current font.
   * @param isBold If we should use the bold fontWeight.
   */
  protected _getFont(isBold: boolean, isItalic: boolean): string {
    const fontWeight = isBold ? this._optionsService.rawOptions.fontWeightBold : this._optionsService.rawOptions.fontWeight;
    const fontStyle = isItalic ? 'italic' : '';

    return `${fontStyle} ${fontWeight} ${this._optionsService.rawOptions.fontSize * window.devicePixelRatio}px ${this._optionsService.rawOptions.fontFamily}`;
  }

  private _getContrastColor(cell: CellData, x: number, y: number): IColor | undefined {
    // Get any decoration foreground/background overrides, this must be fetched before the early
    // exist but applied after inverse
    let bgOverride: number | undefined;
    let fgOverride: number | undefined;
    let isTop = false;
    for (const d of this._decorationService.getDecorationsAtCell(x, y)) {
      if (d.options.layer !== 'top' && isTop) {
        continue;
      }
      if (d.backgroundColorRGB) {
        bgOverride = d.backgroundColorRGB.rgba;
      }
      if (d.foregroundColorRGB) {
        fgOverride = d.foregroundColorRGB.rgba;
      }
      isTop = d.options.layer === 'top';
    }

    // Apply selection foreground if applicable
    if (!isTop) {
      if (this._colors.selectionForeground && this._isCellInSelection(x, y)) {
        fgOverride = this._colors.selectionForeground.rgba;
      }
    }

    if (!bgOverride && !fgOverride && (this._optionsService.rawOptions.minimumContrastRatio === 1 || excludeFromContrastRatioDemands(cell.getCode()))) {
      return undefined;
    }

    if (!bgOverride && !fgOverride) {
      // Try get from cache
      const adjustedColor = this._colors.contrastCache.getColor(cell.bg, cell.fg);
      if (adjustedColor !== undefined) {
        return adjustedColor || undefined;
      }
    }

    let fgColor = cell.getFgColor();
    let fgColorMode = cell.getFgColorMode();
    let bgColor = cell.getBgColor();
    let bgColorMode = cell.getBgColorMode();
    const isInverse = !!cell.isInverse();
    const isBold = !!cell.isInverse();
    if (isInverse) {
      const temp = fgColor;
      fgColor = bgColor;
      bgColor = temp;
      const temp2 = fgColorMode;
      fgColorMode = bgColorMode;
      bgColorMode = temp2;
    }

    const bgRgba = this._resolveBackgroundRgba(bgOverride !== undefined ? Attributes.CM_RGB : bgColorMode, bgOverride ?? bgColor, isInverse);
    const fgRgba = this._resolveForegroundRgba(fgColorMode, fgColor, isInverse, isBold);
    let result = rgba.ensureContrastRatio(bgOverride ?? bgRgba, fgOverride ?? fgRgba, this._optionsService.rawOptions.minimumContrastRatio);

    if (!result) {
      if (!fgOverride) {
        this._colors.contrastCache.setColor(cell.bg, cell.fg, null);
        return undefined;
      }
      // If it was an override and there was no contrast change, set as the result
      result = fgOverride;
    }

    const color: IColor = {
      css: channels.toCss(
        (result >> 24) & 0xFF,
        (result >> 16) & 0xFF,
        (result >> 8) & 0xFF
      ),
      rgba: result
    };
    if (!bgOverride && !fgOverride) {
      this._colors.contrastCache.setColor(cell.bg, cell.fg, color);
    }

    return color;
  }

  private _resolveBackgroundRgba(bgColorMode: number, bgColor: number, inverse: boolean): number {
    switch (bgColorMode) {
      case Attributes.CM_P16:
      case Attributes.CM_P256:
        return this._colors.ansi[bgColor].rgba;
      case Attributes.CM_RGB:
        return bgColor << 8;
      case Attributes.CM_DEFAULT:
      default:
        if (inverse) {
          return this._colors.foreground.rgba;
        }
        return this._colors.background.rgba;
    }
  }

  private _resolveForegroundRgba(fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean): number {
    switch (fgColorMode) {
      case Attributes.CM_P16:
      case Attributes.CM_P256:
        if (this._optionsService.rawOptions.drawBoldTextInBrightColors && bold && fgColor < 8) {
          fgColor += 8;
        }
        return this._colors.ansi[fgColor].rgba;
      case Attributes.CM_RGB:
        return fgColor << 8;
      case Attributes.CM_DEFAULT:
      default:
        if (inverse) {
          return this._colors.background.rgba;
        }
        return this._colors.foreground.rgba;
    }
  }

  private _isCellInSelection(x: number, y: number): boolean {
    const start = this._selectionStart;
    const end = this._selectionEnd;
    if (!start || !end) {
      return false;
    }
    if (this._columnSelectMode) {
      return x >= start[0] && y >= start[1] &&
        x < end[0] && y < end[1];
    }
    return (y > start[1] && y < end[1]) ||
        (start[1] === end[1] && y === start[1] && x >= start[0] && x < end[0]) ||
        (start[1] < end[1] && y === end[1] && x < end[0]) ||
        (start[1] < end[1] && y === start[1] && x >= start[0]);
  }
}

