import type { Model } from "@ironcalc/wasm";
import { columnNameFromNumber } from "@ironcalc/wasm";
import type { Cell } from "../types";
import type { WorkbookState } from "../workbookState";
import {
  LAST_COLUMN,
  LAST_ROW,
  defaultTextColor,
  gridColor,
  gridSeparatorColor,
  headerBackground,
  headerBorderColor,
  headerSelectedBackground,
  headerSelectedColor,
  headerTextColor,
  outlineColor,
} from "./constants";

export interface CanvasSettings {
  model: Model;
  width: number;
  height: number;
  workbookState: WorkbookState;
  elements: {
    canvas: HTMLCanvasElement;
    cellOutline: HTMLDivElement;
    areaOutline: HTMLDivElement;
    cellOutlineHandle: HTMLDivElement;
    extendToOutline: HTMLDivElement;
    columnGuide: HTMLDivElement;
    rowGuide: HTMLDivElement;
    columnHeaders: HTMLDivElement;
    editor: HTMLDivElement;
  };
  onColumnWidthChanges: (sheet: number, column: number, width: number) => void;
  onRowHeightChanges: (sheet: number, row: number, height: number) => void;
}

export const fonts = {
  regular: 'Inter, "Adjusted Arial Fallback", sans-serif',
  mono: '"Fira Mono", "Adjusted Courier New Fallback", serif',
};

export const headerRowHeight = 24;
export const headerColumnWidth = 30;
export const devicePixelRatio = window.devicePixelRatio || 1;

export const defaultCellFontFamily = fonts.regular;
export const headerFontFamily = fonts.regular;
export const frozenSeparatorWidth = 3;

// Get a 10% transparency of an hex color
function hexToRGBA10Percent(colorHex: string): string {
  // Remove the leading hash (#) if present
  const hex = colorHex.replace(/^#/, "");

  // Parse the hex color
  const red = Number.parseInt(hex.substring(0, 2), 16);
  const green = Number.parseInt(hex.substring(2, 4), 16);
  const blue = Number.parseInt(hex.substring(4, 6), 16);

  // Set the alpha (opacity) to 0.1 (10%)
  const alpha = 0.1;

  // Return the RGBA color string
  return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

export default class WorksheetCanvas {
  sheetWidth: number;

  sheetHeight: number;

  width: number;

  height: number;

  ctx: CanvasRenderingContext2D;

  canvas: HTMLCanvasElement;

  editor: HTMLDivElement;

  areaOutline: HTMLDivElement;

  cellOutline: HTMLDivElement;

  cellOutlineHandle: HTMLDivElement;

  extendToOutline: HTMLDivElement;

  workbookState: WorkbookState;

  model: Model;

  rowGuide: HTMLDivElement;

  columnHeaders: HTMLDivElement;

  columnGuide: HTMLDivElement;

  onColumnWidthChanges: (sheet: number, column: number, width: number) => void;

  onRowHeightChanges: (sheet: number, row: number, height: number) => void;

  constructor(options: CanvasSettings) {
    this.model = options.model;
    this.sheetWidth = 0;
    this.sheetHeight = 0;
    this.canvas = options.elements.canvas;
    this.width = options.width;
    this.height = options.height;
    this.ctx = this.setContext();
    this.workbookState = options.workbookState;
    this.editor = options.elements.editor;

    this.cellOutline = options.elements.cellOutline;
    this.cellOutlineHandle = options.elements.cellOutlineHandle;
    this.areaOutline = options.elements.areaOutline;
    this.extendToOutline = options.elements.extendToOutline;
    this.rowGuide = options.elements.rowGuide;
    this.columnGuide = options.elements.columnGuide;
    this.columnHeaders = options.elements.columnHeaders;

    this.onColumnWidthChanges = options.onColumnWidthChanges;
    this.onRowHeightChanges = options.onRowHeightChanges;
    this.resetHeaders();
  }

  setScrollPosition(scrollPosition: { left: number; top: number }): void {
    // We ony scroll whole rows and whole columns
    // left, top are maximized with constraints:
    //    1. left <= scrollPosition.left
    //    2. top <= scrollPosition.top
    //    3. (left, top) are the absolute coordinates of a cell
    const { column } = this.getBoundedColumn(scrollPosition.left);
    const { row } = this.getBoundedRow(scrollPosition.top);
    this.model.setTopLeftVisibleCell(row, column);
  }

  resetHeaders(): void {
    for (const handle of this.columnHeaders.querySelectorAll(
      ".column-resize-handle",
    )) {
      handle.remove();
    }
    for (const columnSeparator of this.columnHeaders.querySelectorAll(
      ".frozen-column-separator",
    )) {
      columnSeparator.remove();
    }
    for (const header of this.columnHeaders.children) {
      (header as HTMLDivElement).classList.add("column-header");
    }
  }

  setContext(): CanvasRenderingContext2D {
    const { canvas, width, height } = this;
    const context = canvas.getContext("2d");
    if (!context) {
      throw new Error(
        "This browser does not support 2-dimensional canvas rendering contexts.",
      );
    }
    // If the devicePixelRatio is 2 then the canvas is twice as large to avoid blurring.
    canvas.width = width * devicePixelRatio;
    canvas.height = height * devicePixelRatio;
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
    context.scale(devicePixelRatio, devicePixelRatio);
    return context;
  }

  setSize(size: { width: number; height: number }): void {
    this.width = size.width;
    this.height = size.height;
    this.ctx = this.setContext();
  }

  /**
   * This is the height of the frozen rows including the width of the separator
   * It returns 0 if the are no frozen rows
   */
  getFrozenRowsHeight(): number {
    const frozenRows = this.model.getFrozenRowsCount(
      this.model.getSelectedSheet(),
    );
    if (frozenRows === 0) {
      return 0;
    }
    let frozenRowsHeight = 0;
    for (let row = 1; row <= frozenRows; row += 1) {
      frozenRowsHeight += this.getRowHeight(this.model.getSelectedSheet(), row);
    }
    return frozenRowsHeight + frozenSeparatorWidth;
  }

  /**
   * This is the width of the frozen columns including the width of the separator
   * It returns 0 if the are no frozen columns
   */
  getFrozenColumnsWidth(): number {
    const frozenColumns = this.model.getFrozenColumnsCount(
      this.model.getSelectedSheet(),
    );
    if (frozenColumns === 0) {
      return 0;
    }
    let frozenColumnsWidth = 0;
    for (let column = 1; column <= frozenColumns; column += 1) {
      frozenColumnsWidth += this.getColumnWidth(
        this.model.getSelectedSheet(),
        column,
      );
    }
    return frozenColumnsWidth + frozenSeparatorWidth;
  }

  // Get the visible cells (aside from the frozen rows and columns)
  getVisibleCells(): {
    topLeftCell: Cell;
    bottomRightCell: Cell;
  } {
    const view = this.model.getSelectedView();
    const selectedSheet = view.sheet;
    const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
    const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
    const rowTop = Math.max(frozenRows + 1, view.top_row);
    let rowBottom = rowTop;
    const columnLeft = Math.max(frozenColumns + 1, view.left_column);
    let columnRight = columnLeft;
    const frozenColumnsWidth = this.getFrozenColumnsWidth();
    const frozenRowsHeight = this.getFrozenRowsHeight();
    let y = headerRowHeight + frozenRowsHeight;
    for (let row = rowTop; row <= LAST_ROW; row += 1) {
      const rowHeight = this.getRowHeight(selectedSheet, row);
      if (y >= this.height - rowHeight || row === LAST_ROW) {
        rowBottom = row;
        break;
      }
      y += rowHeight;
    }

    let x = headerColumnWidth + frozenColumnsWidth;
    for (let column = columnLeft; column <= LAST_COLUMN; column += 1) {
      const columnWidth = this.getColumnWidth(selectedSheet, column);
      if (x >= this.width - columnWidth || column === LAST_COLUMN) {
        columnRight = column;
        break;
      }
      x += columnWidth;
    }

    const cells = {
      topLeftCell: { row: rowTop, column: columnLeft },
      bottomRightCell: { row: rowBottom, column: columnRight },
    };

    return cells;
  }

  /**
   * Returns the {row, top} of the row whose upper y coordinate (top) is maximum and less or equal than maxTop
   * Both top and maxTop are absolute coordinates
   */
  getBoundedRow(maxTop: number): { row: number; top: number } {
    const selectedSheet = this.model.getSelectedSheet();
    let top = 0;
    let row = 1 + this.model.getFrozenRowsCount(selectedSheet);
    while (row <= LAST_ROW && top <= maxTop) {
      const height = this.getRowHeight(selectedSheet, row);
      if (top + height <= maxTop) {
        top += height;
      } else {
        break;
      }
      row += 1;
    }
    return { row, top };
  }

  private getBoundedColumn(maxLeft: number): { column: number; left: number } {
    let left = 0;
    const selectedSheet = this.model.getSelectedSheet();
    let column = 1 + this.model.getFrozenColumnsCount(selectedSheet);
    while (left <= maxLeft && column <= LAST_COLUMN) {
      const width = this.getColumnWidth(selectedSheet, column);
      if (width + left <= maxLeft) {
        left += width;
      } else {
        break;
      }
      column += 1;
    }
    return { column, left };
  }

  /**
   * Returns the minimum we can scroll to the left so that
   * targetColumn is fully visible.
   * Returns the the first visible column and the scrollLeft position
   */
  getMinScrollLeft(targetColumn: number): number {
    const columnStart =
      1 + this.model.getFrozenColumnsCount(this.model.getSelectedSheet());
    /** Distance from the first non frozen cell to the right border of column*/
    let distance = 0;
    for (let column = columnStart; column <= targetColumn; column += 1) {
      const width = this.getColumnWidth(this.model.getSelectedSheet(), column);
      distance += width;
    }
    /** Minimum we need to scroll so that `column` is visible */
    const minLeft =
      distance - this.width + this.getFrozenColumnsWidth() + headerColumnWidth;

    // Because scrolling is quantified, we only scroll whole columns,
    // we need to find the minimum quantum that is larger than minLeft
    let left = 0;
    for (let column = columnStart; column <= LAST_COLUMN; column += 1) {
      const width = this.getColumnWidth(this.model.getSelectedSheet(), column);
      if (left < minLeft) {
        left += width;
      } else {
        break;
      }
    }
    return left;
  }

  private renderCell(
    row: number,
    column: number,
    x: number,
    y: number,
    width: number,
    height: number,
  ): void {
    const selectedSheet = this.model.getSelectedSheet();
    const style = this.model.getCellStyle(selectedSheet, row, column);

    let backgroundColor = "#FFFFFF";
    if (style.fill.fg_color) {
      backgroundColor = style.fill.fg_color;
    }
    const cellGridColor = this.model.getShowGridLines(selectedSheet)
      ? gridColor
      : backgroundColor;

    const fontSize = 13;
    let font = `${fontSize}px ${defaultCellFontFamily}`;
    let textColor = defaultTextColor;
    if (style.font) {
      textColor = style.font.color;
      font = style.font.b ? `bold ${font}` : `400 ${font}`;
      if (style.font.i) {
        font = `italic ${font}`;
      }
    }
    let horizontalAlign = "general";
    if (style.alignment?.horizontal) {
      horizontalAlign = style.alignment.horizontal;
    }
    let verticalAlign = "bottom";
    if (style.alignment?.vertical) {
      verticalAlign = style.alignment.vertical;
    }

    const context = this.ctx;
    context.font = font;
    context.fillStyle = backgroundColor;
    context.fillRect(x, y, width, height);
    context.fillStyle = textColor;

    // Let's do the border
    // Algorithm:
    //  * we use the border if present
    //  * otherwise we use the border of the adjacent cell
    //  * otherwise we use the color of the background
    //  * otherwise we use the background color of the adjacent cell
    //  * if everything else fails we use the default grid color
    // We only set the left and top borders (right and bottom are set later)
    const border = style.border;

    let borderLeftColor = cellGridColor;
    let borderLeftWidth = 1;
    if (border.left) {
      borderLeftColor = border.left.color;
      switch (border.left.style) {
        case "thin":
          break;
        case "medium":
          borderLeftWidth = 2;
          break;
        case "thick":
          borderLeftWidth = 3;
      }
    } else {
      const leftStyle = this.model.getCellStyle(selectedSheet, row, column - 1);
      if (leftStyle.border.right) {
        borderLeftColor = leftStyle.border.right.color;
        switch (leftStyle.border.right.style) {
          case "thin":
            break;
          case "medium":
            borderLeftWidth = 2;
            break;
          case "thick":
            borderLeftWidth = 3;
        }
      } else if (style.fill.fg_color) {
        borderLeftColor = style.fill.fg_color;
      } else if (leftStyle.fill.fg_color) {
        borderLeftColor = leftStyle.fill.fg_color;
      }
    }
    context.beginPath();
    context.strokeStyle = borderLeftColor;
    context.lineWidth = borderLeftWidth;
    context.moveTo(x, y);
    context.lineTo(x, y + height);
    context.stroke();

    let borderTopColor = cellGridColor;
    let borderTopWidth = 1;
    if (border.top) {
      borderTopColor = border.top.color;
      switch (border.top.style) {
        case "thin":
          break;
        case "medium":
          borderTopWidth = 2;
          break;
        case "thick":
          borderTopWidth = 3;
      }
    } else {
      const topStyle = this.model.getCellStyle(selectedSheet, row - 1, column);
      if (topStyle.border.bottom) {
        borderTopColor = topStyle.border.bottom.color;
        switch (topStyle.border.bottom.style) {
          case "thin":
            break;
          case "medium":
            borderTopWidth = 2;
            break;
          case "thick":
            borderTopWidth = 3;
        }
      } else if (style.fill.fg_color) {
        borderTopColor = style.fill.fg_color;
      } else if (topStyle.fill.fg_color) {
        borderTopColor = topStyle.fill.fg_color;
      }
    }
    context.beginPath();
    context.strokeStyle = borderTopColor;
    context.lineWidth = borderTopWidth;
    context.moveTo(x, y);
    context.lineTo(x + width, y);
    context.stroke();

    // Number = 1,
    // Text = 2,
    // LogicalValue = 4,
    // ErrorValue = 16,
    // Array = 64,
    // CompoundData = 128,

    const cellType = this.model.getCellType(selectedSheet, row, column);
    const fullText = this.model.getFormattedCellValue(
      selectedSheet,
      row,
      column,
    );
    const padding = 4;
    if (horizontalAlign === "general") {
      if (cellType === 1) {
        horizontalAlign = "right";
      } else if (cellType === 4) {
        horizontalAlign = "center";
      } else {
        horizontalAlign = "left";
      }
    }

    // Create a rectangular clipping region
    context.save();
    context.beginPath();
    context.rect(x, y, width, height);
    context.clip();

    // Is there any better parameter?
    const lineHeight = fontSize;

    fullText.split("\n").forEach((text, line) => {
      const textWidth = context.measureText(text).width;
      let textX: number;
      let textY: number;
      // The idea is that in the present font-size and default row heigh,
      // top/bottom and center horizontalAlign coincide
      const verticalPadding = 4;
      if (horizontalAlign === "right") {
        textX = width - padding + x - textWidth / 2;
      } else if (horizontalAlign === "center") {
        textX = x + width / 2;
      } else {
        // left aligned
        textX = padding + x + textWidth / 2;
      }
      if (verticalAlign === "bottom") {
        textY = y + height - fontSize / 2 - verticalPadding;
      } else if (verticalAlign === "center") {
        textY = y + height / 2;
      } else {
        // aligned top
        textY = y + fontSize / 2 + verticalPadding;
      }
      textY += line * lineHeight;
      context.fillText(text, textX, textY);
      if (style.font) {
        if (style.font.u) {
          // There are no text-decoration in canvas. You have to do the underline yourself.
          const offset = Math.floor(fontSize / 2);
          context.beginPath();
          context.strokeStyle = textColor;
          context.lineWidth = 1;
          context.moveTo(textX - textWidth / 2, textY + offset);
          context.lineTo(textX + textWidth / 2, textY + offset);
          context.stroke();
        }
        if (style.font.strike) {
          // There are no text-decoration in canvas. You have to do the strikethrough yourself.
          context.beginPath();
          context.strokeStyle = textColor;
          context.lineWidth = 1;
          context.moveTo(textX - textWidth / 2, textY);
          context.lineTo(textX + textWidth / 2, textY);
          context.stroke();
        }
      }
    });

    // remove the clipping region
    context.restore();
  }

  // Column and row headers with their handles
  private addColumnResizeHandle(
    x: number,
    column: number,
    columnWidth: number,
  ): void {
    const div = document.createElement("div");
    div.className = "column-resize-handle";
    div.style.left = `${x - 1}px`;
    div.style.height = `${headerRowHeight}px`;
    this.columnHeaders.insertBefore(div, null);

    let initPageX = 0;
    const resizeHandleMove = (event: MouseEvent): void => {
      if (columnWidth + event.pageX - initPageX > 0) {
        div.style.left = `${x + event.pageX - initPageX - 1}px`;
        this.columnGuide.style.left = `${
          headerColumnWidth + x + event.pageX - initPageX
        }px`;
      }
    };
    let resizeHandleUp = (event: MouseEvent): void => {
      div.style.opacity = "0";
      this.columnGuide.style.display = "none";
      document.removeEventListener("mousemove", resizeHandleMove);
      document.removeEventListener("mouseup", resizeHandleUp);
      const newColumnWidth = columnWidth + event.pageX - initPageX;
      this.onColumnWidthChanges(
        this.model.getSelectedSheet(),
        column,
        newColumnWidth,
      );
    };
    resizeHandleUp = resizeHandleUp.bind(this);
    div.addEventListener("mousedown", (event) => {
      div.style.opacity = "1";
      this.columnGuide.style.display = "block";
      this.columnGuide.style.left = `${headerColumnWidth + x}px`;
      initPageX = event.pageX;
      document.addEventListener("mousemove", resizeHandleMove);
      document.addEventListener("mouseup", resizeHandleUp);
    });
  }

  private addRowResizeHandle(y: number, row: number, rowHeight: number): void {
    const div = document.createElement("div");
    div.className = "row-resize-handle";
    div.style.top = `${y - 1}px`;
    div.style.width = `${headerColumnWidth}px`;
    const sheet = this.model.getSelectedSheet();
    this.canvas.parentElement?.insertBefore(div, null);
    let initPageY = 0;
    /* istanbul ignore next */
    const resizeHandleMove = (event: MouseEvent): void => {
      if (rowHeight + event.pageY - initPageY > 0) {
        div.style.top = `${y + event.pageY - initPageY - 1}px`;
        this.rowGuide.style.top = `${y + event.pageY - initPageY}px`;
      }
    };
    let resizeHandleUp = (event: MouseEvent): void => {
      div.style.opacity = "0";
      this.rowGuide.style.display = "none";
      document.removeEventListener("mousemove", resizeHandleMove);
      document.removeEventListener("mouseup", resizeHandleUp);
      const newRowHeight = rowHeight + event.pageY - initPageY - 1;
      this.onRowHeightChanges(sheet, row, newRowHeight);
    };
    resizeHandleUp = resizeHandleUp.bind(this);
    /* istanbul ignore next */
    div.addEventListener("mousedown", (event) => {
      div.style.opacity = "1";
      this.rowGuide.style.display = "block";
      this.rowGuide.style.top = `${y}px`;
      initPageY = event.pageY;
      document.addEventListener("mousemove", resizeHandleMove);
      document.addEventListener("mouseup", resizeHandleUp);
    });
  }

  private styleColumnHeader(
    width: number,
    div: HTMLDivElement,
    selected: boolean,
  ): void {
    div.style.boxSizing = "border-box";
    div.style.width = `${width}px`;
    div.style.height = `${headerRowHeight}px`;
    div.style.backgroundColor = selected
      ? headerSelectedBackground
      : headerBackground;
    div.style.color = selected ? headerSelectedColor : headerTextColor;
    div.style.fontWeight = "bold";
    div.style.borderLeft = `1px solid ${headerBorderColor}`;
    div.style.borderTop = `1px solid ${headerBorderColor}`;
    if (selected) {
      div.style.borderBottom = `1px solid ${outlineColor}`;
      div.classList.add("selected");
    } else {
      div.classList.remove("selected");
    }
  }

  private removeHandles(): void {
    const root = this.canvas.parentElement;
    if (root) {
      for (const handle of root.querySelectorAll(".row-resize-handle"))
        handle.remove();
    }
  }

  private renderRowHeaders(
    frozenRows: number,
    topLeftCell: Cell,
    bottomRightCell: Cell,
  ): void {
    const { sheet: selectedSheet, range } = this.model.getSelectedView();
    let rowStart = range[0];
    let rowEnd = range[2];
    if (rowStart > rowEnd) {
      [rowStart, rowEnd] = [rowEnd, rowStart];
    }
    const context = this.ctx;

    let topLeftCornerY = headerRowHeight + 0.5;
    const firstRow = frozenRows === 0 ? topLeftCell.row : 1;

    for (let row = firstRow; row <= bottomRightCell.row; row += 1) {
      const rowHeight = this.getRowHeight(selectedSheet, row);
      const selected = row >= rowStart && row <= rowEnd;
      context.fillStyle = headerBorderColor;
      context.fillRect(0.5, topLeftCornerY, headerColumnWidth, rowHeight);
      context.fillStyle = selected
        ? headerSelectedBackground
        : headerBackground;
      context.fillRect(
        0.5,
        topLeftCornerY + 0.5,
        headerColumnWidth,
        rowHeight - 1,
      );
      if (selected) {
        context.fillStyle = outlineColor;
        context.fillRect(headerColumnWidth - 1, topLeftCornerY, 1, rowHeight);
      }
      context.fillStyle = selected ? headerSelectedColor : headerTextColor;
      context.font = `bold 12px ${defaultCellFontFamily}`;
      context.fillText(
        `${row}`,
        headerColumnWidth / 2,
        topLeftCornerY + rowHeight / 2,
        headerColumnWidth,
      );
      topLeftCornerY += rowHeight;
      this.addRowResizeHandle(topLeftCornerY, row, rowHeight);
      if (row === frozenRows) {
        topLeftCornerY += frozenSeparatorWidth;
        row = topLeftCell.row - 1;
      }
    }
  }

  private renderColumnHeaders(
    frozenColumns: number,
    firstColumn: number,
    lastColumn: number,
  ): void {
    const { columnHeaders } = this;
    let deltaX = 0;
    const { range } = this.model.getSelectedView();
    let columnStart = range[1];
    let columnEnd = range[3];
    if (columnStart > columnEnd) {
      [columnStart, columnEnd] = [columnEnd, columnStart];
    }
    for (const header of columnHeaders.querySelectorAll(".column-header"))
      header.remove();
    for (const handle of columnHeaders.querySelectorAll(
      ".column-resize-handle",
    ))
      handle.remove();
    for (const separator of columnHeaders.querySelectorAll(
      ".frozen-column-separator",
    ))
      separator.remove();
    columnHeaders.style.fontFamily = headerFontFamily;
    columnHeaders.style.fontSize = "12px";
    columnHeaders.style.height = `${headerRowHeight}px`;
    columnHeaders.style.lineHeight = `${headerRowHeight}px`;
    columnHeaders.style.left = `${headerColumnWidth}px`;

    // Frozen headers
    for (let column = 1; column <= frozenColumns; column += 1) {
      const selected = column >= columnStart && column <= columnEnd;
      deltaX += this.addColumnHeader(deltaX, column, selected);
    }

    if (frozenColumns !== 0) {
      const div = document.createElement("div");
      div.className = "frozen-column-separator";
      div.style.width = `${frozenSeparatorWidth}px`;
      div.style.height = `${headerRowHeight}`;
      div.style.display = "inline-block";
      div.style.backgroundColor = gridSeparatorColor;
      this.columnHeaders.insertBefore(div, null);
      deltaX += frozenSeparatorWidth;
    }

    for (let column = firstColumn; column <= lastColumn; column += 1) {
      const selected = column >= columnStart && column <= columnEnd;
      deltaX += this.addColumnHeader(deltaX, column, selected);
    }

    columnHeaders.style.width = `${deltaX}px`;
  }

  private addColumnHeader(
    deltaX: number,
    column: number,
    selected: boolean,
  ): number {
    const columnWidth = this.getColumnWidth(
      this.model.getSelectedSheet(),
      column,
    );
    const div = document.createElement("div");
    div.className = "column-header";
    div.textContent = columnNameFromNumber(column);
    this.columnHeaders.insertBefore(div, null);

    this.styleColumnHeader(columnWidth, div, selected);
    this.addColumnResizeHandle(deltaX + columnWidth, column, columnWidth);
    return columnWidth;
  }

  getSheetDimensions(): [number, number] {
    let x = headerColumnWidth;
    for (let column = 1; column < LAST_COLUMN + 1; column += 1) {
      x += this.getColumnWidth(this.model.getSelectedSheet(), column);
    }
    let y = headerRowHeight;
    for (let row = 1; row < LAST_ROW + 1; row += 1) {
      y += this.getRowHeight(this.model.getSelectedSheet(), row);
    }
    this.sheetWidth = Math.floor(
      x + this.getColumnWidth(this.model.getSelectedSheet(), LAST_COLUMN),
    );
    this.sheetHeight = Math.floor(
      y + 2 * this.getRowHeight(this.model.getSelectedSheet(), LAST_ROW),
    );
    return [this.sheetWidth, this.sheetHeight];
  }

  /**
   * Returns the css clip in the canvas of an html element
   * This is used so we do not see the outlines in the row and column headers
   * NB: A _different_ (better!) approach would be to have separate canvases for the headers
   * Then the sheet canvas would have it's own bounding box.
   * That's tomorrows problem.
   * PS: Please, do not use this function. If at all we can use the clip-path property
   */
  private getClipCSS(
    x: number,
    y: number,
    width: number,
    height: number,
    includeFrozenRows: boolean,
    includeFrozenColumns: boolean,
  ): string {
    if (!includeFrozenRows && !includeFrozenColumns) {
      return "";
    }
    const frozenColumnsWidth = includeFrozenColumns
      ? this.getFrozenColumnsWidth()
      : 0;
    const frozenRowsHeight = includeFrozenRows ? this.getFrozenRowsHeight() : 0;
    const yMinCanvas = headerRowHeight + frozenRowsHeight;
    const xMinCanvas = headerColumnWidth + frozenColumnsWidth;

    const xMaxCanvas =
      xMinCanvas + this.width - headerColumnWidth - frozenColumnsWidth;
    const yMaxCanvas =
      yMinCanvas + this.height - headerRowHeight - frozenRowsHeight;

    const topClip = y < yMinCanvas ? yMinCanvas - y : 0;
    const leftClip = x < xMinCanvas ? xMinCanvas - x : 0;

    // We don't strictly need to clip on the right and bottom edges because
    // text is hidden anyway
    const rightClip = x + width > xMaxCanvas ? xMaxCanvas - x : width + 4;
    const bottomClip = y + height > yMaxCanvas ? yMaxCanvas - y : height + 4;
    return `rect(${topClip}px ${rightClip}px ${bottomClip}px ${leftClip}px)`;
  }

  private getAreaDimensions(
    startRow: number,
    startColumn: number,
    endRow: number,
    endColumn: number,
  ): [number, number] {
    const [xStart, yStart] = this.getCoordinatesByCell(startRow, startColumn);
    let [xEnd, yEnd] = this.getCoordinatesByCell(endRow, endColumn);
    xEnd += this.getColumnWidth(this.model.getSelectedSheet(), endColumn);
    yEnd += this.getRowHeight(this.model.getSelectedSheet(), endRow);
    const frozenRows = this.model.getFrozenRowsCount(
      this.model.getSelectedSheet(),
    );
    const frozenColumns = this.model.getFrozenColumnsCount(
      this.model.getSelectedSheet(),
    );
    if (frozenRows !== 0 || frozenColumns !== 0) {
      let [xFrozenEnd, yFrozenEnd] = this.getCoordinatesByCell(
        frozenRows,
        frozenColumns,
      );
      xFrozenEnd += this.getColumnWidth(
        this.model.getSelectedSheet(),
        frozenColumns,
      );
      yFrozenEnd += this.getRowHeight(
        this.model.getSelectedSheet(),
        frozenRows,
      );
      if (startRow <= frozenRows && endRow > frozenRows) {
        yEnd = Math.max(yEnd, yFrozenEnd);
      }
      if (startColumn <= frozenColumns && endColumn > frozenColumns) {
        xEnd = Math.max(xEnd, xFrozenEnd);
      }
    }
    return [Math.abs(xEnd - xStart), Math.abs(yEnd - yStart)];
  }

  /**
   * Returns the coordinates relative to the canvas.
   * (headerColumnWidth, headerRowHeight) being the coordinates
   * for the top left corner of the first visible cell
   */
  getCoordinatesByCell(row: number, column: number): [number, number] {
    const selectedSheet = this.model.getSelectedSheet();
    const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
    const frozenColumnsWidth = this.getFrozenColumnsWidth();
    const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
    const frozenRowsHeight = this.getFrozenRowsHeight();
    const { topLeftCell } = this.getVisibleCells();
    let x: number;
    let y: number;
    if (row <= frozenRows) {
      // row is one of the frozen rows
      y = headerRowHeight;
      for (let r = 1; r < row; r += 1) {
        y += this.getRowHeight(selectedSheet, r);
      }
    } else if (row >= topLeftCell.row) {
      // row is bellow the frozen rows
      y = headerRowHeight + frozenRowsHeight;
      for (let r = topLeftCell.row; r < row; r += 1) {
        y += this.getRowHeight(selectedSheet, r);
      }
    } else {
      // row is _above_ the frozen rows
      y = headerRowHeight + frozenRowsHeight;
      for (let r = topLeftCell.row; r > row; r -= 1) {
        y -= this.getRowHeight(selectedSheet, r - 1);
      }
    }
    if (column <= frozenColumns) {
      // It is one of the frozen columns
      x = headerColumnWidth;
      for (let c = 1; c < column; c += 1) {
        x += this.getColumnWidth(selectedSheet, c);
      }
    } else if (column >= topLeftCell.column) {
      // column is to the right of the frozen columns
      x = headerColumnWidth + frozenColumnsWidth;
      for (let c = topLeftCell.column; c < column; c += 1) {
        x += this.getColumnWidth(selectedSheet, c);
      }
    } else {
      // column is to the left of the frozen columns
      x = headerColumnWidth + frozenColumnsWidth;
      for (let c = topLeftCell.column; c > column; c -= 1) {
        x -= this.getColumnWidth(selectedSheet, c - 1);
      }
    }
    return [Math.floor(x), Math.floor(y)];
  }

  /**
   * (x, y) are the relative coordinates of a cell WRT the canvas
   * getCellByCoordinates(headerColumnWidth, headerRowHeight) will return the first visible cell.
   * Note: If there are frozen rows/columns for some particular coordinates (x, y)
   * there might be two cells. This method returns the visible one.
   */
  getCellByCoordinates(
    x: number,
    y: number,
  ): { row: number; column: number } | null {
    const frozenColumns = this.model.getFrozenColumnsCount(
      this.model.getSelectedSheet(),
    );
    const frozenColumnsWidth = this.getFrozenColumnsWidth();
    const frozenRows = this.model.getFrozenRowsCount(
      this.model.getSelectedSheet(),
    );
    const frozenRowsHeight = this.getFrozenRowsHeight();
    let column = 0;
    let cellX = headerColumnWidth;
    const { topLeftCell } = this.getVisibleCells();
    if (x < headerColumnWidth) {
      column = topLeftCell.column;
      while (cellX >= x) {
        column -= 1;
        if (column < 1) {
          column = 1;
          break;
        }
        cellX -= this.getColumnWidth(this.model.getSelectedSheet(), column);
      }
    } else if (x < headerColumnWidth + frozenColumnsWidth) {
      while (cellX <= x) {
        column += 1;
        cellX += this.getColumnWidth(this.model.getSelectedSheet(), column);
        // This cannot happen (would mean cellX > headerColumnWidth + frozenColumnsWidth)
        if (column > frozenColumns) {
          /* istanbul ignore next */
          return null;
        }
      }
    } else {
      cellX = headerColumnWidth + frozenColumnsWidth;
      column = topLeftCell.column - 1;
      while (cellX <= x) {
        column += 1;
        if (column > LAST_COLUMN) {
          return null;
        }
        cellX += this.getColumnWidth(this.model.getSelectedSheet(), column);
      }
    }
    let cellY = headerRowHeight;
    let row = 0;
    if (y < headerRowHeight) {
      row = topLeftCell.row;
      while (cellY >= y) {
        row -= 1;
        if (row < 1) {
          row = 1;
          break;
        }
        cellY -= this.getRowHeight(this.model.getSelectedSheet(), row);
      }
    } else if (y < headerRowHeight + frozenRowsHeight) {
      while (cellY <= y) {
        row += 1;
        cellY += this.getRowHeight(this.model.getSelectedSheet(), row);
        // This cannot happen (would mean cellY > headerRowHeight + frozenRowsHeight)
        if (row > frozenRows) {
          /* istanbul ignore next */
          return null;
        }
      }
    } else {
      cellY = headerRowHeight + frozenRowsHeight;
      row = topLeftCell.row - 1;
      while (cellY <= y) {
        row += 1;
        if (row > LAST_ROW) {
          row = LAST_ROW;
          break;
        }
        cellY += this.getRowHeight(this.model.getSelectedSheet(), row);
      }
    }
    if (row < 1) row = 1;
    if (column < 1) column = 1;
    return { row, column };
  }

  private drawExtendToArea(): void {
    const { extendToOutline } = this;
    const extendToArea = this.workbookState.getExtendToArea();
    if (extendToArea === null) {
      extendToOutline.style.visibility = "hidden";
      return;
    }
    extendToOutline.style.visibility = "visible";

    let { rowStart, rowEnd, columnStart, columnEnd } = extendToArea;
    if (rowStart > rowEnd) {
      [rowStart, rowEnd] = [rowEnd, rowStart];
    }
    if (columnStart > columnEnd) {
      [columnStart, columnEnd] = [columnEnd, columnStart];
    }

    const [areaX, areaY] = this.getCoordinatesByCell(rowStart, columnStart);
    const [areaWidth, areaHeight] = this.getAreaDimensions(
      rowStart,
      columnStart,
      rowEnd,
      columnEnd,
    );
    // const { border } = extendToArea;
    extendToOutline.style.border = `1px dashed ${outlineColor}`;
    extendToOutline.style.borderRadius = "3px";
    // switch (border) {
    //   case 'left': {
    //     extendToOutline.style.borderLeft = 'none';
    //     extendToOutline.style.borderTopLeftRadius = '0px';
    //     extendToOutline.style.borderBottomLeftRadius = '0px';

    //     break;
    //   }
    //   case 'right': {
    //     extendToOutline.style.borderRight = 'none';
    //     extendToOutline.style.borderTopRightRadius = '0px';
    //     extendToOutline.style.borderBottomRightRadius = '0px';

    //     break;
    //   }
    //   case 'top': {
    //     extendToOutline.style.borderTop = 'none';
    //     extendToOutline.style.borderTopRightRadius = '0px';
    //     extendToOutline.style.borderTopLeftRadius = '0px';

    //     break;
    //   }
    //   case 'bottom': {
    //     extendToOutline.style.borderBottom = 'none';
    //     extendToOutline.style.borderBottomRightRadius = '0px';
    //     extendToOutline.style.borderBottomLeftRadius = '0px';

    //     break;
    //   }
    //   default:
    //     break;
    // }
    const padding = 1;
    extendToOutline.style.left = `${areaX - padding}px`;
    extendToOutline.style.top = `${areaY - padding}px`;
    extendToOutline.style.width = `${areaWidth + 2 * padding}px`;
    extendToOutline.style.height = `${areaHeight + 2 * padding}px`;
  }

  private getColumnWidth(sheet: number, column: number): number {
    return Math.round(this.model.getColumnWidth(sheet, column) * 1.25);
  }

  private getRowHeight(sheet: number, row: number): number {
    return Math.round(this.model.getRowHeight(sheet, row) * 1.25);
  }

  private drawCellOutline(): void {
    const [selectedSheet, selectedRow, selectedColumn] =
      this.model.getSelectedCell();
    const { topLeftCell } = this.getVisibleCells();
    const frozenRows = this.model.getFrozenRowsCount(selectedSheet);
    const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
    const [x, y] = this.getCoordinatesByCell(selectedRow, selectedColumn);
    const style = this.model.getCellStyle(
      selectedSheet,
      selectedRow,
      selectedColumn,
    );
    const padding = -1;
    const width =
      this.getColumnWidth(selectedSheet, selectedColumn) + 2 * padding;
    const height = this.getRowHeight(selectedSheet, selectedRow) + 2 * padding;

    const { cellOutline, editor, areaOutline, cellOutlineHandle } = this;
    const cellEditing = null;

    cellOutline.style.visibility = "visible";
    cellOutlineHandle.style.visibility = "visible";
    if (
      (selectedRow < topLeftCell.row && selectedRow > frozenRows) ||
      (selectedColumn < topLeftCell.column && selectedColumn > frozenColumns)
    ) {
      cellOutline.style.visibility = "hidden";
      cellOutlineHandle.style.visibility = "hidden";
    }

    if (this.workbookState.getEditingCell()?.sheet === selectedSheet) {
      editor.style.left = `${x + 3}px`;
      editor.style.top = `${y + 3}px`;
    } else {
      // If the editing cell is not in the same sheet as the selected sheet
      // we take the editor out of view
      editor.style.left = "-9999px";
      editor.style.top = "-9999px";
    }

    editor.style.width = `${width - 1}px`;
    editor.style.height = `${height - 1}px`;

    // Position the cell outline and clip it
    cellOutline.style.left = `${x - padding - 1}px`;
    cellOutline.style.top = `${y - padding - 1}px`;
    // Reset CSS properties
    cellOutline.style.minWidth = "";
    cellOutline.style.minHeight = "";
    cellOutline.style.maxWidth = "";
    cellOutline.style.maxHeight = "";
    cellOutline.style.overflow = "hidden";
    // New properties
    cellOutline.style.width = `${width}px`;
    cellOutline.style.height = `${height}px`;
    if (cellEditing) {
      cellOutline.style.fontWeight = style.font.b ? "bold" : "normal";
      cellOutline.style.fontStyle = style.font.i ? "italic" : "normal";
      // cellOutline.style.backgroundColor = style.fill.fg_color;
      // TODO: Should we add the same color as the text?
      // Only if it is not a formula?
      // cellOutline.style.color = style.font.color;
    } else {
      cellOutline.style.background = "none";
    }
    // border is 2px so line-height must be height - 4
    cellOutline.style.lineHeight = `${height - 4}px`;
    let {
      range: [rowStart, columnStart, rowEnd, columnEnd],
    } = this.model.getSelectedView();
    if (rowStart > rowEnd) {
      [rowStart, rowEnd] = [rowEnd, rowStart];
    }
    if (columnStart > columnEnd) {
      [columnStart, columnEnd] = [columnEnd, columnStart];
    }
    let handleX: number;
    let handleY: number;
    // Position the selected area outline
    if (columnStart === columnEnd && rowStart === rowEnd) {
      areaOutline.style.visibility = "hidden";
      [handleX, handleY] = this.getCoordinatesByCell(rowStart, columnStart);
      handleX += this.getColumnWidth(selectedSheet, columnStart);
      handleY += this.getRowHeight(selectedSheet, rowStart);
    } else {
      areaOutline.style.visibility = "visible";
      cellOutlineHandle.style.visibility = "visible";
      const [areaX, areaY] = this.getCoordinatesByCell(rowStart, columnStart);
      const [areaWidth, areaHeight] = this.getAreaDimensions(
        rowStart,
        columnStart,
        rowEnd,
        columnEnd,
      );
      handleX = areaX + areaWidth;
      handleY = areaY + areaHeight;
      areaOutline.style.left = `${areaX - padding}px`;
      areaOutline.style.top = `${areaY - padding}px`;
      areaOutline.style.width = `${areaWidth + 2 * padding}px`;
      areaOutline.style.height = `${areaHeight + 2 * padding}px`;
      const clipLeft = rowStart < topLeftCell.row && rowStart > frozenRows;
      const clipTop =
        columnStart < topLeftCell.column && columnStart > frozenColumns;
      areaOutline.style.clip = this.getClipCSS(
        areaX,
        areaY,
        areaWidth + 2 * padding,
        areaHeight + 2 * padding,
        clipLeft,
        clipTop,
      );
      areaOutline.style.border = `1px solid ${outlineColor}`;
      // hide the handle if it is out of the visible area
      if (
        (rowEnd > frozenRows && rowEnd < topLeftCell.row - 1) ||
        (columnEnd > frozenColumns && columnEnd < topLeftCell.column - 1)
      ) {
        cellOutlineHandle.style.visibility = "hidden";
      }

      // This is in case the selection starts in the frozen area and ends outside of the frozen area
      // but we have scrolled out the selection.
      if (
        rowStart <= frozenRows &&
        rowEnd > frozenRows &&
        rowEnd < topLeftCell.row - 1
      ) {
        areaOutline.style.borderBottom = "None";
        cellOutlineHandle.style.visibility = "hidden";
      }
      if (
        columnStart <= frozenColumns &&
        columnEnd > frozenColumns &&
        columnEnd < topLeftCell.column - 1
      ) {
        areaOutline.style.borderRight = "None";
        cellOutlineHandle.style.visibility = "hidden";
      }
    }

    // draw the handle
    if (cellEditing !== null) {
      cellOutlineHandle.style.visibility = "hidden";
      return;
    }
    const handleBBox = cellOutlineHandle.getBoundingClientRect();
    const handleWidth = handleBBox.width;
    const handleHeight = handleBBox.height;
    cellOutlineHandle.style.left = `${handleX - handleWidth / 2 - 1}px`;
    cellOutlineHandle.style.top = `${handleY - handleHeight / 2 - 1}px`;
  }

  private drawActiveRanges(topLeftCell: Cell, bottomRightCell: Cell): void {
    let activeRanges = this.workbookState.getActiveRanges();
    const ctx = this.ctx;
    ctx.setLineDash([2, 2]);
    const referencedRange =
      this.workbookState.getEditingCell()?.referencedRange || null;
    if (referencedRange) {
      activeRanges = activeRanges.concat([
        {
          ...referencedRange.range,
          color: "#343423",
        },
      ]);
    }
    const activeRangesCount = activeRanges.length;
    for (let rangeIndex = 0; rangeIndex < activeRangesCount; rangeIndex += 1) {
      const range = activeRanges[rangeIndex];

      const allowedOffset = 1; // to make borders look nicer
      const minRow = topLeftCell.row - allowedOffset;
      const maxRow = bottomRightCell.row + allowedOffset;
      const minColumn = topLeftCell.column - allowedOffset;
      const maxColumn = bottomRightCell.column + allowedOffset;

      if (
        minRow <= range.rowEnd &&
        range.rowStart <= maxRow &&
        minColumn <= range.columnEnd &&
        range.columnStart < maxColumn
      ) {
        // Range in the viewport.
        const displayRange: typeof range = {
          ...range,
          rowStart: Math.max(minRow, range.rowStart),
          rowEnd: Math.min(maxRow, range.rowEnd),
          columnStart: Math.max(minColumn, range.columnStart),
          columnEnd: Math.min(maxColumn, range.columnEnd),
        };
        const [xStart, yStart] = this.getCoordinatesByCell(
          displayRange.rowStart,
          displayRange.columnStart,
        );
        const [xEnd, yEnd] = this.getCoordinatesByCell(
          displayRange.rowEnd + 1,
          displayRange.columnEnd + 1,
        );
        ctx.strokeStyle = range.color;
        ctx.lineWidth = 1;
        ctx.strokeRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
        ctx.fillStyle = hexToRGBA10Percent(range.color);
        ctx.fillRect(xStart, yStart, xEnd - xStart, yEnd - yStart);
      }
    }

    ctx.setLineDash([]);
  }

  renderSheet(): void {
    console.time("renderSheet");
    this._renderSheet();
    console.timeEnd("renderSheet");
  }

  private _renderSheet(): void {
    const context = this.ctx;
    const { canvas } = this;
    const selectedSheet = this.model.getSelectedSheet();
    context.lineWidth = 1;
    context.textAlign = "center";
    context.textBaseline = "middle";

    // Clear the canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    this.removeHandles();

    const { topLeftCell, bottomRightCell } = this.getVisibleCells();

    const frozenColumns = this.model.getFrozenColumnsCount(selectedSheet);
    const frozenRows = this.model.getFrozenRowsCount(selectedSheet);

    // Draw frozen rows and columns (top-left-pane)
    let x = headerColumnWidth + 0.5;
    let y = headerRowHeight + 0.5;
    for (let row = 1; row <= frozenRows; row += 1) {
      const rowHeight = this.getRowHeight(selectedSheet, row);
      x = headerColumnWidth;
      for (let column = 1; column <= frozenColumns; column += 1) {
        const columnWidth = this.getColumnWidth(selectedSheet, column);
        this.renderCell(row, column, x, y, columnWidth, rowHeight);
        x += columnWidth;
      }
      y += rowHeight;
    }
    if (frozenRows === 0 && frozenColumns !== 0) {
      x = headerColumnWidth;
      for (let column = 1; column <= frozenColumns; column += 1) {
        x += this.getColumnWidth(selectedSheet, column);
      }
    }

    const frozenOffset = frozenSeparatorWidth / 2;
    // If there are frozen rows draw a separator
    if (frozenRows) {
      context.beginPath();
      context.lineWidth = frozenSeparatorWidth;
      context.strokeStyle = gridSeparatorColor;
      context.moveTo(0, y + frozenOffset);
      context.lineTo(this.width, y + frozenOffset);
      y += frozenSeparatorWidth;
      context.stroke();
      context.lineWidth = 1;
    }

    // If there are frozen columns draw a separator
    if (frozenColumns) {
      context.beginPath();
      context.lineWidth = frozenSeparatorWidth;
      context.strokeStyle = gridSeparatorColor;
      context.moveTo(x + frozenOffset, 0);
      context.lineTo(x + frozenOffset, this.height);
      x += frozenSeparatorWidth;
      context.stroke();
      context.lineWidth = 1;
    }

    const frozenX = x;
    const frozenY = y;
    // Draw frozen rows (top-right pane)
    y = headerRowHeight;
    for (let row = 1; row <= frozenRows; row += 1) {
      x = frozenX;
      const rowHeight = this.getRowHeight(selectedSheet, row);
      for (
        let { column } = topLeftCell;
        column <= bottomRightCell.column;
        column += 1
      ) {
        const columnWidth = this.getColumnWidth(selectedSheet, column);
        this.renderCell(row, column, x, y, columnWidth, rowHeight);
        x += columnWidth;
      }
      y += rowHeight;
    }

    // Draw frozen columns (bottom-left pane)
    y = frozenY;
    for (let { row } = topLeftCell; row <= bottomRightCell.row; row += 1) {
      x = headerColumnWidth;
      const rowHeight = this.getRowHeight(selectedSheet, row);

      for (let column = 1; column <= frozenColumns; column += 1) {
        const columnWidth = this.getColumnWidth(selectedSheet, column);
        this.renderCell(row, column, x, y, columnWidth, rowHeight);

        x += columnWidth;
      }
      y += rowHeight;
    }

    // Render all remaining cells (bottom-right pane)
    y = frozenY;
    for (let { row } = topLeftCell; row <= bottomRightCell.row; row += 1) {
      x = frozenX;
      const rowHeight = this.getRowHeight(selectedSheet, row);

      for (
        let { column } = topLeftCell;
        column <= bottomRightCell.column;
        column += 1
      ) {
        const columnWidth = this.getColumnWidth(selectedSheet, column);
        this.renderCell(row, column, x, y, columnWidth, rowHeight);

        x += columnWidth;
      }
      y += rowHeight;
    }

    // Draw column headers
    this.renderColumnHeaders(
      frozenColumns,
      topLeftCell.column,
      bottomRightCell.column,
    );

    // Draw row headers
    this.renderRowHeaders(frozenRows, topLeftCell, bottomRightCell);

    // square in the top left corner
    context.fillStyle = headerBorderColor;
    context.fillRect(0, 0, headerColumnWidth, headerRowHeight);

    this.drawCellOutline();
    this.drawExtendToArea();
    this.drawActiveRanges(topLeftCell, bottomRightCell);
  }
}
