import { AnchorOffset, Figure, FigureUI } from "../types/figure";
import { ViewportsGetters } from "../types/getters";
import {
  CellPosition,
  Dimension,
  HeaderDimensions,
  HeaderIndex,
  PaneDivision,
  Pixel,
  PixelPosition,
  Position,
  SheetViewDimensions,
  UID,
  Zone,
} from "../types/misc";
import {
  DOMCoordinates,
  DOMDimension,
  EdgeScrollInfo,
  Rect,
  ScrollDirection,
  SheetDOMScrollInfo,
  Viewport,
} from "../types/rendering";
import { scrollDelay } from "./edge_scrolling";
import { InternalViewport } from "./internal_viewport";
import { clip, isDefined, range } from "./misc";
import { intersection, positionToZone } from "./zones";

interface ViewportZones {
  topLeft?: Zone;
  bottomLeft?: Zone;
  topRight?: Zone;
  bottomRight: Zone;
}

type SheetViewports = {
  topLeft?: InternalViewport;
  bottomLeft?: InternalViewport;
  topRight?: InternalViewport;
  bottomRight: InternalViewport;
};

interface PositionedViewport {
  viewport: InternalViewport;
  viewportX: Pixel;
  viewportY: Pixel;
}

interface ViewportCollectionArgs {
  getters: ViewportsGetters;
  paneDivision: Record<UID, PaneDivision>;
  sheetViewWidth: Pixel;
  sheetViewHeight: Pixel;
  getZoomLevel: () => number;
  getFooterSize: () => number;
  zoneToDisplay?: Zone;
}

/**
 *   EdgeScrollCases Schema
 *
 *  The dots/double dots represent a freeze (= a split of viewports)
 *  In this example, we froze vertically between columns D and E
 *  and horizontally between rows 4 and 5.
 *
 *  One can see that we scrolled horizontally from column E to G and
 *  vertically from row 5 to 7.
 *
 *     A  B  C  D   G  H  I  J  K  L  M  N  O  P  Q  R  S  T
 *     _______________________________________________________
 *  1 |           :                                           |
 *  2 |           :                                           |
 *  3 |           :        B   ↑                 6            |
 *  4 |           :        |   |                 |            |
 *     ····················+···+·················+············|
 *  7 |           :        |   |                 |            |
 *  8 |           :        ↓   2                 |            |
 *  9 |           :                              |            |
 * 10 |       A --+--→                           |            |
 * 11 |           :                              |            |
 * 12 |           :                              |            |
 * 13 |        ←--+-- 1                          |            |
 * 14 |           :                              |        3 --+--→
 * 15 |           :                              |            |
 * 16 |           :                              |            |
 * 17 |       5 --+-------------------------------------------+--→
 * 18 |           :                              |            |
 * 19 |           :                  4           |            |
 * 20 |           :                  |           |            |
 *     ______________________________+___________| ____________
 *                                   |           |
 *                                   ↓           ↓
 */

export class ViewportCollection {
  getters: ViewportsGetters;
  viewports: Record<UID, SheetViewports | undefined> = {};

  private gridOffsetX: Pixel = 0;
  private gridOffsetY: Pixel = 0;

  private paneDivision: Record<UID, PaneDivision>;
  private sheetViewWidth: Pixel;
  private sheetViewHeight: Pixel;
  getZoomLevel: () => number;
  private getFooterSize: () => number;
  private zoneToDisplay?: Zone;

  constructor(args: ViewportCollectionArgs) {
    this.getters = args.getters;
    this.paneDivision = args.paneDivision;
    this.sheetViewWidth = args.sheetViewWidth;
    this.sheetViewHeight = args.sheetViewHeight;
    this.getZoomLevel = args.getZoomLevel;
    this.getFooterSize = args.getFooterSize;
    this.zoneToDisplay = args.zoneToDisplay;
  }

  changeViewportArgs(args: Partial<ViewportCollectionArgs>) {
    for (const key in args) {
      this[key] = args[key];
    }
    for (const sheetId of this.getters.getSheetIds()) {
      this.resetViewports(sheetId);
    }
  }

  /**
   * Return the index of a column given an offset x, based on the viewport left
   * visible cell.
   * It returns -1 if no column is found.
   */
  getColIndex(sheetId: UID, x: Pixel): HeaderIndex {
    return Math.max(
      ...this.getPositionedViewports(sheetId).map(({ viewport, viewportX }) => {
        if (x < viewportX || x > viewportX + viewport.viewportWidth) {
          return -1;
        }
        return viewport.getColIndex(x - viewportX);
      })
    );
  }

  /**
   * Return the index of a row given an offset y, based on the viewport top
   * visible cell.
   * It returns -1 if no row is found.
   */
  getRowIndex(sheetId: UID, y: Pixel): HeaderIndex {
    return Math.max(
      ...this.getPositionedViewports(sheetId).map(({ viewport, viewportY }) => {
        if (y < viewportY || y > viewportY + viewport.viewportHeight) {
          return -1;
        }
        return viewport.getRowIndex(y - viewportY);
      })
    );
  }

  getSheetViewDimensionWithHeaders(): DOMDimension {
    return {
      width: this.sheetViewWidth + this.gridOffsetX,
      height: this.sheetViewHeight + this.gridOffsetY,
    };
  }

  getSheetViewDimension(): DOMDimension {
    return {
      width: this.sheetViewWidth,
      height: this.sheetViewHeight,
    };
  }

  getGridOffset(): DOMCoordinates {
    return { x: this.gridOffsetX, y: this.gridOffsetY };
  }

  /**
   * Return the DOM scroll info of the given sheet, ie. the offset between the viewport left/top side and
   * the grid left/top side, corresponding to the scroll of the scrollbars and not snapped to the grid.
   */
  getSheetScrollInfo(sheetId: UID): SheetDOMScrollInfo {
    const viewport = this.getMainInternalViewport(sheetId);
    return {
      scrollX: viewport.offsetX,
      scrollY: viewport.offsetY,
    };
  }

  getSheetViewVisibleCols(sheetId: UID): HeaderIndex[] {
    const viewports = this.getSubViewports(sheetId).map((viewport) => viewport);

    //TODO make another commit to improve this
    return [...new Set(viewports.map((v) => range(v.left, v.right + 1)).flat())].filter(
      (col) => col >= 0 && !this.getters.isHeaderHidden(sheetId, "COL", col)
    );
  }

  getSheetViewVisibleRows(sheetId: UID): HeaderIndex[] {
    const viewports = this.getSubViewports(sheetId).map((viewport) => viewport);
    return [...new Set(viewports.map((v) => range(v.top, v.bottom + 1)).flat())].filter(
      (row) => row >= 0 && !this.getters.isHeaderHidden(sheetId, "ROW", row)
    );
  }

  /**
   * Get the positions of all the cells that are visible in the viewport, taking merges into account.
   */
  getVisibleCellPositions(sheetId: UID): CellPosition[] {
    const visibleCols = this.getSheetViewVisibleCols(sheetId);
    const visibleRows = this.getSheetViewVisibleRows(sheetId);

    const positions: CellPosition[] = [];
    for (const col of visibleCols) {
      for (const row of visibleRows) {
        const position = { sheetId, col, row };
        const mainPosition = this.getters.getMainCellPosition(position);
        if (mainPosition.row !== row || mainPosition.col !== col) {
          continue;
        }
        positions.push(position);
      }
    }
    return positions;
  }

  /**
   * Return the main viewport maximum size relative to the client size.
   */
  getMainViewportRect(sheetId: UID): Rect {
    const viewport = this.getMainInternalViewport(sheetId);
    const { width, height } = viewport.getMaxSize();
    const { paneWidth: x, paneHeight: y } = this.getPanesDimensions(sheetId);
    return { x, y, width, height };
  }

  getMaximumSheetOffset(sheetId: UID): { maxOffsetX: Pixel; maxOffsetY: Pixel } {
    const { width, height } = this.getMainViewportRect(sheetId);
    const viewport = this.getMainInternalViewport(sheetId);
    return {
      maxOffsetX: Math.max(0, width - viewport.viewportWidth),
      maxOffsetY: Math.max(0, height - viewport.viewportHeight),
    };
  }

  getColRowOffsetInViewport(
    sheetId: UID,
    dimension: Dimension,
    referenceHeaderIndex: HeaderIndex,
    targetHeaderIndex: HeaderIndex
  ): Pixel {
    if (targetHeaderIndex < referenceHeaderIndex) {
      return -this.getColRowOffsetInViewport(
        sheetId,
        dimension,
        targetHeaderIndex,
        referenceHeaderIndex
      );
    }

    const visibleHeaders =
      dimension === "COL"
        ? this.getSheetViewVisibleCols(sheetId)
        : this.getSheetViewVisibleRows(sheetId);
    const startIndex = visibleHeaders.findIndex((header) => referenceHeaderIndex >= header);
    let endIndex = visibleHeaders.findIndex((header) => targetHeaderIndex <= header);
    endIndex = endIndex === -1 ? visibleHeaders.length : endIndex;
    const relevantIndexes = visibleHeaders.slice(startIndex, endIndex);
    let offset = 0;
    for (const i of relevantIndexes) {
      offset += this.getters.getHeaderSize(sheetId, dimension, i);
    }
    return offset * this.getZoomLevel();
  }

  /**
   * Check if a given position is visible in the viewport.
   */
  isVisibleInViewport({ sheetId, col, row }: CellPosition): boolean {
    return this.getSubViewports(sheetId).some((viewport) => viewport.isVisible(col, row));
  }

  isZoneVisibleInViewport(sheetId: UID, zone: Zone): boolean {
    return this.getSubViewports(sheetId).some((viewport) => viewport.isZoneVisible(zone));
  }

  // => returns the new offset
  getEdgeScrollCol(sheetId: UID, x: number, previousX: number, startingX: number): EdgeScrollInfo {
    let canEdgeScroll = false;
    let direction: ScrollDirection = 0;
    let delay = 0;
    /** 4 cases : See EdgeScrollCases Schema at the top
     * 1. previous in XRight > XLeft
     * 3. previous in XRight > outside
     * 5. previous in Left > outside
     * A. previous in Left > right
     * with X a position taken in the bottomRIght (aka scrollable) viewport
     */

    // FIXME: xSplit is wrong when there is a `this.zoneToDisplay` (should use `getPanesDimensions`). But the edge scroll
    // is never used in this case, and its logic is probably too complex to try to fix w/o any real world testing.
    const { xSplit } = this.getPaneDivisions(sheetId);
    const { width } = this.getSheetViewDimension();
    const { x: offsetCorrectionX } = this.getMainViewportCoordinates(sheetId);
    const currentOffsetX = this.getSheetScrollInfo(sheetId).scrollX;

    if (x > width) {
      // 3 & 5
      canEdgeScroll = true;
      delay = scrollDelay(x - width);
      direction = 1;
    } else if (x < offsetCorrectionX && startingX >= offsetCorrectionX && currentOffsetX > 0) {
      // 1
      canEdgeScroll = true;
      delay = scrollDelay(offsetCorrectionX - x);
      direction = -1;
    } else if (xSplit && previousX < offsetCorrectionX && x > offsetCorrectionX) {
      // A
      canEdgeScroll = true;
      delay = scrollDelay(x);
      direction = "reset";
    }
    return { canEdgeScroll, direction, delay };
  }

  getEdgeScrollRow(sheetId: UID, y: number, previousY: number, startingY: number): EdgeScrollInfo {
    let canEdgeScroll = false;
    let direction: ScrollDirection = 0;
    let delay = 0;
    /** 4 cases : See EdgeScrollCases Schema at the top
     * 2. previous in XBottom > XTop
     * 4. previous in XRight > outside
     * 6. previous in Left > outside
     * B. previous in Left > right
     * with X a position taken in the bottomRIght (aka scrollable) viewport
     */

    // FIXME: ySplit is wrong when there is a `this.zoneToDisplay` (should use `getPanesDimensions`). But the edge scroll
    // is never used in this case, and its logic is probably too complex to try to fix w/o any real world testing.
    const { ySplit } = this.getPaneDivisions(sheetId);

    const { height } = this.getSheetViewDimension();
    const { y: offsetCorrectionY } = this.getMainViewportCoordinates(sheetId);
    const currentOffsetY = this.getSheetScrollInfo(sheetId).scrollY;

    if (y > height) {
      // 4 & 6
      canEdgeScroll = true;
      delay = scrollDelay(y - height);
      direction = 1;
    } else if (y < offsetCorrectionY && startingY >= offsetCorrectionY && currentOffsetY > 0) {
      // 2
      canEdgeScroll = true;
      delay = scrollDelay(offsetCorrectionY - y);
      direction = -1;
    } else if (ySplit && previousY < offsetCorrectionY && y > offsetCorrectionY) {
      // B
      canEdgeScroll = true;
      delay = scrollDelay(y);
      direction = "reset";
    }
    return { canEdgeScroll, direction, delay };
  }

  /**
   * Computes the coordinates and size to draw the zone on the canvas
   */
  getVisibleRect(sheetId: UID, zone: Zone): Rect {
    const rect = this.getVisibleRectWithoutHeaders(sheetId, zone);
    return { ...rect, x: rect.x + this.gridOffsetX, y: rect.y + this.gridOffsetY };
  }

  /**
   * Computes the coordinates and size to draw the zone on the canvas after it has been zoomed
   */
  getVisibleRectWithZoom(sheetId: UID, zone: Zone): Rect {
    const zoom = this.getZoomLevel();
    const rect = this.getVisibleRectWithoutHeaders(sheetId, zone);
    rect.width = rect.width * zoom;
    rect.height = rect.height * zoom;
    rect.x = rect.x * zoom + this.gridOffsetX * zoom;
    rect.y = rect.y * zoom + this.gridOffsetY * zoom;
    return rect;
  }

  /**
   * Computes the coordinates and size to draw the zone without taking the grid offset into account
   */
  getVisibleRectWithoutHeaders(sheetId: UID, zone: Zone): Rect {
    return this.mapViewportsToRect(sheetId, (viewport) => viewport.getVisibleRect(zone));
  }

  /**
   * Computes the actual size and position (:Rect) of the zone on the canvas
   * regardless of the viewport dimensions.
   */
  getRect(sheetId: UID, zone: Zone): Rect {
    const rect = this.mapViewportsToRect(sheetId, (viewport) => viewport.getFullRect(zone));
    return { ...rect, x: rect.x + this.gridOffsetX, y: rect.y + this.gridOffsetY };
  }

  /**
   * Computes the actual size and position (:Rect) of the zone on the canvas
   * regardless of the viewport dimensions.
   */
  getRectWithoutHeaders(sheetId: UID, zone: Zone): Rect {
    return this.mapViewportsToRect(sheetId, (viewport) => viewport.getFullRect(zone));
  }

  /**
   * Returns the position of the MainViewport relatively to the start of the grid (without headers)
   * It corresponds to the summed dimensions of the visible cols/rows (in x/y respectively)
   * situated before the pane divisions.
   */
  getMainViewportCoordinates(sheetId: UID): DOMCoordinates {
    const { paneWidth, paneHeight } = this.getPanesDimensions(sheetId);
    return { x: paneWidth, y: paneHeight };
  }

  /**
   * Returns the size, start and end coordinates of a column relative to the left
   * column of the current viewport
   */
  getColDimensionsInViewport(sheetId: UID, col: HeaderIndex): HeaderDimensions {
    const { top } = this.getMainInternalViewport(sheetId);
    const zone = {
      left: col,
      right: col,
      top: top,
      bottom: top,
    };
    const { x, width } = this.getVisibleRect(sheetId, zone);
    const start = x - this.gridOffsetX;
    return { start, size: width, end: start + width };
  }

  /**
   * Returns the size, start and end coordinates of a row relative to the top row
   * of the current viewport
   */
  getRowDimensionsInViewport(sheetId: UID, row: HeaderIndex): HeaderDimensions {
    const { left } = this.getMainInternalViewport(sheetId);
    const zone = {
      left: 0,
      right: left,
      top: row,
      bottom: row,
    };
    const { y, height } = this.getVisibleRect(sheetId, zone);
    const start = y - this.gridOffsetY;
    return { start, size: height, end: start + height };
  }

  getAllSheetViewportsZonesAndRect(sheetId: UID): { zone: Zone; rect: Rect }[] {
    return this.getPositionedViewports(sheetId).map(({ viewport, viewportX, viewportY }) => {
      return {
        zone: viewport,
        rect: {
          x: viewportX + this.gridOffsetX,
          y: viewportY + this.gridOffsetY,
          ...viewport.getMaxSize(),
        },
      };
    });
  }

  ensureMainViewportExist(sheetId: UID) {
    if (!this.viewports[sheetId]) {
      this.resetViewports(sheetId);
    }
  }

  getPanesDimensions(sheetId: UID) {
    const viewportZones = this.getViewportZones(sheetId);

    let paneWidth = 0;
    if (viewportZones.bottomLeft) {
      const start = this.getters.getColDimensions(sheetId, viewportZones.bottomLeft.left).start;
      const end = this.getters.getColDimensions(sheetId, viewportZones.bottomLeft.right).end;
      paneWidth = end - start;
    }

    let paneHeight = 0;
    if (viewportZones.topRight) {
      const start = this.getters.getRowDimensions(sheetId, viewportZones.topRight.top).start;
      const end = this.getters.getRowDimensions(sheetId, viewportZones.topRight.bottom).end;
      paneHeight = end - start;
    }

    return { paneWidth, paneHeight };
  }

  getPositionedViewports(sheetId: UID): PositionedViewport[] {
    this.ensureMainViewportExist(sheetId);

    const viewports = this.viewports[sheetId]!;
    const { paneWidth, paneHeight } = this.getPanesDimensions(sheetId);

    const viewPorts: PositionedViewport[] = [];
    if (viewports.topLeft) {
      viewPorts.push({ viewport: viewports.topLeft, viewportX: 0, viewportY: 0 });
    }
    if (viewports.topRight) {
      viewPorts.push({ viewport: viewports.topRight, viewportX: paneWidth, viewportY: 0 });
    }
    if (viewports.bottomLeft) {
      viewPorts.push({ viewport: viewports.bottomLeft, viewportX: 0, viewportY: paneHeight });
    }
    viewPorts.push({
      viewport: viewports.bottomRight,
      viewportX: paneWidth,
      viewportY: paneHeight,
    });
    return viewPorts;
  }

  getSubViewports(sheetId: UID): InternalViewport[] {
    this.ensureMainViewportExist(sheetId);
    return Object.values(this.viewports[sheetId]!).filter(isDefined);
  }

  checkPositiveDimension(dims: SheetViewDimensions) {
    if (dims.width < 0 || dims.height < 0) {
      return false;
    }
    return true;
  }

  checkValuesAreDifferent(dims: SheetViewDimensions) {
    const { height, width } = this.getSheetViewDimension();
    if (
      dims.gridOffsetX === this.gridOffsetX &&
      dims.gridOffsetY === this.gridOffsetY &&
      dims.width === width &&
      dims.height === height
    ) {
      return false;
    }
    return true;
  }

  checkScrollingDirection(
    sheetId: UID,
    { offsetX, offsetY }: { offsetX: Pixel; offsetY: Pixel }
  ): boolean {
    const pane = this.getMainInternalViewport(sheetId);
    if (
      (!pane.canScrollHorizontally && offsetX > 0) ||
      (!pane.canScrollVertically && offsetY > 0)
    ) {
      return false;
    }
    return true;
  }

  checkIfViewportsWillChange(
    sheetId: UID,
    { offsetX, offsetY }: { offsetX: Pixel; offsetY: Pixel }
  ) {
    const { maxOffsetX, maxOffsetY } = this.getMaximumSheetOffset(sheetId);
    const willScroll = this.getSubViewports(sheetId).some((viewport) =>
      viewport.willNewOffsetScrollViewport(
        clip(offsetX, 0, maxOffsetX),
        clip(offsetY, 0, maxOffsetY)
      )
    );
    return willScroll ? true : false;
  }

  getMainViewport(sheetId: UID): Viewport {
    const viewport = this.getMainInternalViewport(sheetId);
    return {
      top: viewport.top,
      left: viewport.left,
      bottom: viewport.bottom,
      right: viewport.right,
    };
  }

  getMainInternalViewport(sheetId: UID): InternalViewport {
    this.ensureMainViewportExist(sheetId);
    return this.viewports[sheetId]!.bottomRight;
  }

  /** gets rid of deprecated sheetIds */
  cleanViewports() {
    const newViewport = {};
    for (const sheetId of this.getters.getSheetIds()) {
      newViewport[sheetId] = this.viewports[sheetId];
    }
    this.viewports = newViewport;
  }

  resizeSheetView(dimensions: SheetViewDimensions) {
    this.sheetViewHeight = dimensions.height;
    this.sheetViewWidth = dimensions.width;
    this.gridOffsetX = dimensions.gridOffsetX || 0;
    this.gridOffsetY = dimensions.gridOffsetY || 0;
    this.recomputeViewports();
  }

  recomputeViewports() {
    for (const sheetId of this.getters.getSheetIds()) {
      this.resetViewports(sheetId);
    }
  }

  setSheetViewOffset(sheetId: UID, offsetX: Pixel, offsetY: Pixel) {
    const { maxOffsetX, maxOffsetY } = this.getMaximumSheetOffset(sheetId);
    this.getSubViewports(sheetId).forEach((viewport) =>
      viewport.setViewportOffset(clip(offsetX, 0, maxOffsetX), clip(offsetY, 0, maxOffsetY))
    );
  }

  getViewportOffset(sheetId: UID) {
    return {
      x: this.viewports[sheetId]?.bottomRight.offsetX || 0,
      y: this.viewports[sheetId]?.bottomRight.offsetY || 0,
    };
  }

  resetViewports(sheetId: UID) {
    if (!this.getters.tryGetSheet(sheetId)) {
      return;
    }
    const { paneWidth, paneHeight } = this.getPanesDimensions(sheetId);
    const colOffset = Math.min(paneWidth, this.sheetViewWidth);
    const rowOffset = Math.min(paneHeight, this.sheetViewHeight);
    const unfrozenWidth = Math.max(this.sheetViewWidth - paneWidth, 0);
    const unfrozenHeight = Math.max(this.sheetViewHeight - paneHeight, 0);
    const { xRatio, yRatio } = this.getFrozenSheetViewRatio(sheetId);
    const canScrollHorizontally = xRatio < 1.0;
    const canScrollVertically = yRatio < 1.0;
    const previousOffset = this.getViewportOffset(sheetId);

    const sheetViewports: Partial<SheetViewports> = {};
    const viewportZones = this.getViewportZones(sheetId);

    const topLeftZone = viewportZones.topLeft;
    if (topLeftZone) {
      sheetViewports.topLeft = new InternalViewport({
        getters: this.getters,
        sheetId,
        boundaries: topLeftZone,
        sizeInGrid: { width: colOffset, height: rowOffset },
        canScrollHorizontally: false,
        canScrollVertically: false,
        offsets: { x: 0, y: 0 },
        getFooterSize: () => 0,
      });
    }

    const topRightZone = viewportZones.topRight;
    if (topRightZone) {
      sheetViewports.topRight = new InternalViewport({
        getters: this.getters,
        sheetId,
        boundaries: topRightZone,
        sizeInGrid: { width: unfrozenWidth, height: rowOffset },
        canScrollHorizontally,
        canScrollVertically: false,
        offsets: { x: canScrollHorizontally ? previousOffset.x : 0, y: 0 },
        getFooterSize: () => 0,
      });
    }

    const bottomLeftZone = viewportZones.bottomLeft;
    if (bottomLeftZone) {
      sheetViewports.bottomLeft = new InternalViewport({
        getters: this.getters,
        sheetId,
        boundaries: bottomLeftZone,
        sizeInGrid: { width: colOffset, height: unfrozenHeight },
        canScrollHorizontally: false,
        canScrollVertically,
        offsets: { x: 0, y: canScrollVertically ? previousOffset.y : 0 },
        getFooterSize: this.getFooterSize,
      });
    }

    const bottomRightZone = viewportZones.bottomRight;
    sheetViewports.bottomRight = new InternalViewport({
      getters: this.getters,
      sheetId,
      boundaries: bottomRightZone,
      sizeInGrid: { width: unfrozenWidth, height: unfrozenHeight },
      canScrollHorizontally,
      canScrollVertically,
      offsets: {
        x: canScrollHorizontally ? previousOffset.x : 0,
        y: canScrollVertically ? previousOffset.y : 0,
      },
      getFooterSize: this.getFooterSize,
    });

    this.viewports[sheetId] = sheetViewports as SheetViewports;
  }

  /**
   * Depending on how `this.zoneToDisplay` intersects the viewports, we may need to reorganize the viewports to ensure that
   * `viewports.bottomRight` is always defined.
   *
   * For example if `this.zoneToDisplay` is only intersecting the bottom-left viewport, we need to treat the bottom-left
   * viewport as the bottom-right viewport.
   */
  private reorganizeViewportZones(sheetId: UID, zones: Partial<ViewportZones>): ViewportZones {
    const definedViewports = Object.values(zones).filter((v) => v !== undefined);

    if (definedViewports.length === 1) {
      return { bottomRight: definedViewports[0]! };
    }
    if (definedViewports.length === 4) {
      return zones as ViewportZones;
    }
    if (definedViewports.length === 2) {
      if (zones.bottomRight && (zones.topRight || zones.bottomLeft)) {
        return zones as ViewportZones;
      }
      const { topLeft, topRight, bottomLeft } = zones;
      if (topLeft && topRight) {
        return { bottomLeft: topLeft, bottomRight: topRight };
      } else if (topLeft && bottomLeft) {
        return { topRight: topLeft, bottomRight: bottomLeft };
      }
    }

    throw new Error(`ViewportCollection: Malformed viewports for ${sheetId}`);
  }

  private getViewportZones(sheetId: UID): ViewportZones {
    const { xSplit, ySplit } = this.getPaneDivisions(sheetId);
    const nCols = this.getters.getNumberCols(sheetId);
    const nRows = this.getters.getNumberRows(sheetId);
    const zoneToDisplay = this.zoneToDisplay
      ? intersection(this.zoneToDisplay, this.getters.getSheetZone(sheetId))
      : undefined;
    const zones: Partial<ViewportZones> = {};

    if (xSplit && ySplit) {
      const topLeft = { left: 0, right: xSplit - 1, top: 0, bottom: ySplit - 1 };
      zones.topLeft = zoneToDisplay ? intersection(topLeft, zoneToDisplay) : topLeft;
    }

    if (ySplit) {
      const topRight = { left: xSplit, right: nCols - 1, top: 0, bottom: ySplit - 1 };
      zones.topRight = zoneToDisplay ? intersection(topRight, zoneToDisplay) : topRight;
    }

    if (xSplit) {
      const bottomLeft = { left: 0, right: xSplit - 1, top: ySplit, bottom: nRows - 1 };
      zones.bottomLeft = zoneToDisplay ? intersection(bottomLeft, zoneToDisplay) : bottomLeft;
    }

    const bottomRight = { left: xSplit, right: nCols - 1, top: ySplit, bottom: nRows - 1 };
    zones.bottomRight = zoneToDisplay ? intersection(bottomRight, zoneToDisplay) : bottomRight;

    return this.reorganizeViewportZones(sheetId, zones);
  }

  /**
   * Adjust the viewport such that the anchor position is visible
   */
  refreshViewport(sheetId: UID, anchorPosition: Position) {
    this.getSubViewports(sheetId).forEach((viewport) => {
      viewport.adjustViewportZone();
      viewport.repositionViewport(anchorPosition);
    });
  }

  /**
   * Return the index of a col given a negative offset x, based on the main viewport top
   * visible cell of the main viewport.
   * It returns -1 if no col is found.
   */
  getColIndexLeftOfMainViewport(sheetId: UID, x: Pixel): HeaderIndex {
    if (x >= 0) {
      return -1;
    }
    let col = this.getMainViewport(sheetId).left;
    let colStart =
      -this.getSheetScrollInfo(sheetId).scrollX -
      this.getters.getColRowOffset("COL", col, 0, sheetId);
    for (; x < colStart; col--) {
      colStart -= this.getters.getColSize(sheetId, col - 1);
    }
    return Math.max(col, 0);
  }

  /**
   * Return the index of a row given a negative offset y, based on the main viewport top
   * visible cell of the main viewport.
   * It returns -1 if no row is found.
   */
  getRowIndexTopOfMainViewport(sheetId: UID, y: Pixel): HeaderIndex {
    if (y >= 0) {
      return -1;
    }
    let row = this.getMainViewport(sheetId).top;
    let rowStart =
      -this.getSheetScrollInfo(sheetId).scrollY -
      this.getters.getColRowOffset("ROW", row, 0, sheetId);
    for (; y < rowStart; row--) {
      rowStart -= this.getters.getRowSize(sheetId, row - 1);
    }
    return Math.max(row, 0);
  }

  getVisibleFigures(sheetId: UID): FigureUI[] {
    const result: FigureUI[] = [];
    const figures = this.getters.getFigures(sheetId);
    const { scrollX, scrollY } = this.getSheetScrollInfo(sheetId);
    const { x: offsetCorrectionX, y: offsetCorrectionY } = this.getMainViewportCoordinates(sheetId);
    const { width, height } = this.getSheetViewDimensionWithHeaders();

    for (const figure of figures) {
      const figureUI = this.getFigureUI(sheetId, figure);
      const { x, y } = figureUI;
      if (
        x >= offsetCorrectionX &&
        (x + figure.width < scrollX + offsetCorrectionX || x > width + scrollX + offsetCorrectionX)
      ) {
        continue;
      } else if (
        y >= offsetCorrectionY &&
        (y + figure.height < scrollY + offsetCorrectionY ||
          y > height + scrollY + offsetCorrectionY)
      ) {
        continue;
      }
      result.push(figureUI);
    }
    return result;
  }

  getFigureUI(sheetId: UID, figure: Figure): FigureUI {
    const x = figure.offset.x + this.getters.getColDimensions(sheetId, figure.col).start;
    const y = figure.offset.y + this.getters.getRowDimensions(sheetId, figure.row).start;
    return { ...figure, x, y };
  }

  getPositionAnchorOffset(sheetId: UID, position: PixelPosition): AnchorOffset {
    const { scrollX, scrollY } = this.getSheetScrollInfo(sheetId);
    const x = position.x - scrollX;
    const y = position.y - scrollY;
    let col =
      x >= 0 ? this.getColIndex(sheetId, x) : this.getColIndexLeftOfMainViewport(sheetId, x);
    if (col < 0) {
      // If the position is not visible in any viewport, we take the position in the main viewport
      col = this.getMainInternalViewport(sheetId).searchHeaderIndex("COL", x);
    }
    let row = y >= 0 ? this.getRowIndex(sheetId, y) : this.getRowIndexTopOfMainViewport(sheetId, y);
    if (row < 0) {
      // If the position is not visible in any viewport, we take the position in the main viewport
      row = this.getMainInternalViewport(sheetId).searchHeaderIndex("ROW", y);
    }
    const { x: colX, y: rowY } = this.getRect(sheetId, positionToZone({ col, row }));
    return {
      col,
      row,
      offset: {
        x: Math.max(x - colX + this.gridOffsetX, 0),
        y: Math.max(y - rowY + this.gridOffsetY, 0),
      },
    };
  }

  isPixelPositionVisible(sheetId: UID, position: PixelPosition): boolean {
    const { scrollX, scrollY } = this.getSheetScrollInfo(sheetId);
    const { x: mainViewportX, y: mainViewportY } = this.getMainViewportCoordinates(sheetId);
    const { width, height } = this.getSheetViewDimension();

    if (
      position.x >= mainViewportX &&
      (position.x < mainViewportX + scrollX || position.x > width + scrollX + mainViewportX)
    ) {
      return false;
    }
    if (
      position.y >= mainViewportY &&
      (position.y < mainViewportY + scrollY || position.y > height + scrollY + mainViewportY)
    ) {
      return false;
    }

    return true;
  }

  getFrozenSheetViewRatio(sheetId: UID) {
    const { paneWidth, paneHeight } = this.getPanesDimensions(sheetId);
    const width = this.sheetViewWidth + this.gridOffsetX;
    const height = this.sheetViewHeight + this.gridOffsetY;
    return { xRatio: paneWidth / width, yRatio: paneHeight / height };
  }

  private mapViewportsToRect(
    sheetId: UID,
    rectCallBack: (viewport: InternalViewport) => Rect | undefined
  ): Rect {
    let x: Pixel = Infinity;
    let y: Pixel = Infinity;
    let width: Pixel = 0;
    let height: Pixel = 0;
    let hasViewports: boolean = false;
    for (const positionedViewport of this.getPositionedViewports(sheetId)) {
      const rect = rectCallBack(positionedViewport.viewport);
      if (rect) {
        rect.x += positionedViewport.viewportX;
        rect.y += positionedViewport.viewportY;

        hasViewports = true;
        x = Math.min(x, rect.x);
        y = Math.min(y, rect.y);
        width = Math.max(width, rect.x + rect.width);
        height = Math.max(height, rect.y + rect.height);
      }
    }
    if (!hasViewports) {
      return { x: 0, y: 0, width: 0, height: 0 };
    }
    return { x, y, width: width - x, height: height - y };
  }

  getGridOffsetX(): Pixel {
    return this.gridOffsetX;
  }

  getGridOffsetY(): Pixel {
    return this.gridOffsetY;
  }

  private getPaneDivisions(sheetId: UID): PaneDivision {
    return this.paneDivision[sheetId] || { xSplit: 0, ySplit: 0 };
  }

  setPaneDivision(sheetId: UID, paneDivision: PaneDivision) {
    // FIXME: the pane divisions that should be used are set here, but there is nothing preventing us from
    // using getters.getPaneDivisions anyways. We should either fix the typing/getters access, or handle pane divisions for
    // arbitrary viewports
    this.paneDivision[sheetId] = paneDivision;
  }
}
