// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * Canvases have limits on their maximum size (which is determined by the
 * system). Usually, this limit is fairly large, but can be as small as
 * 4096x4096px on some machines.
 *
 * If we need a super large canvas, we need to use a different approach.
 *
 * Unless the user has a huge monitor, most of the time any sufficiently large
 * canvas will overflow it's container, so we assume this container is set to
 * scroll so that the user can actually see all of the canvas. We can take
 * advantage of the fact that users may only see a small portion of the canvas
 * at a time. So, if we position a small floating canvas element over the
 * viewport of the scrolling container, we can approximate a huge canvas using a
 * much smaller one.
 *
 * Given a target element and it's scrolling container, VirtualCanvas turns an
 * empty HTML element into a "virtual" canvas with virtually unlimited size
 * using the "floating" canvas technique described above.
 */

import {DisposableStack} from './disposable_stack';
import {Bounds2D, Rect2D, Size2D} from './geom';

export type LayoutShiftListener = (
  canvas: HTMLCanvasElement,
  rect: Rect2D,
) => void;

export type CanvasResizeListener = (
  canvas: HTMLCanvasElement,
  width: number,
  height: number,
) => void;

export interface VirtualCanvasOpts {
  // How much buffer to add around the visible window in the scrollable axes.
  // The larger this number, the more we can scroll before triggering a move and
  // update which reduces thrashing when scrolling quickly, but the more canvas
  // will need to be drawn each render cycle.
  readonly overdrawPx: number;

  // This figure controls how close we can get to the edge of the drawn canvas
  // before moving it and triggering a redraw. If 0, we can get all the way to
  // the edge of the canvas before moving it. Larger values will result in more
  // frequent redraws but less chance of seeing blank bits of canvas when
  // scrolling quickly.
  readonly tolerancePx?: number;

  // Which axes should we overdraw? Typically we only want to overdraw in the
  // axes we expect to scroll in. So if we only expect the container to be
  // vertically scrolled, choose 'y'.
  readonly overdrawAxes?: 'none' | 'x' | 'y' | 'both';
}

export class VirtualCanvas implements Disposable {
  private readonly _trash = new DisposableStack();
  private readonly _canvasElement: HTMLCanvasElement;
  private readonly _targetElement: HTMLElement;

  // Describes the offset of the canvas w.r.t. the "target" container
  private _canvasRect: Rect2D;
  private _viewportRect: Rect2D;
  private _viewportLimits: Rect2D;
  private _layoutShiftListener?: LayoutShiftListener;
  private _canvasResizeListener?: CanvasResizeListener;
  private _dpr?: number;

  /**
   * @param targetElement The element to turn into a virtual canvas. The
   * dimensions of this element are used to size the canvas, so ensure this
   * element is sized appropriately.
   * @param containerElement The scrolling container to be used for determining
   * the size and position of the canvas. The targetElement should be a child of
   * this element.
   * @param opts Setup options for the VirtualCanvas.
   */
  constructor(
    targetElement: HTMLElement,
    containerElement: Element,
    opts?: Partial<VirtualCanvasOpts>,
  ) {
    const {
      overdrawPx = 200,
      tolerancePx = 100,
      overdrawAxes: scrollAxes = 'none',
    } = opts ?? {};

    const viewportOversize = overdrawPx - tolerancePx;

    // Returns the rect of the container's viewport W.R.T the target element.
    function getViewportRect() {
      const containerRect = new Rect2D(
        containerElement.getBoundingClientRect(),
      );
      const targetElementRect = targetElement.getBoundingClientRect();

      // Calculate the intersection of the container's viewport and the target
      const intersection = containerRect.intersect(targetElementRect);

      return intersection.reframe(targetElementRect);
    }

    const getCanvasRect = (viewport: Rect2D) => {
      if (this._viewportLimits.contains(viewport)) {
        return this._canvasRect;
      } else {
        const canvasRect = viewport.expand({
          height: scrollAxes === 'both' || scrollAxes === 'y' ? overdrawPx : 0,
          width: scrollAxes === 'both' || scrollAxes === 'x' ? overdrawPx : 0,
        });

        this._viewportLimits = viewport.expand({
          height:
            scrollAxes === 'both' || scrollAxes === 'y' ? viewportOversize : 0,
          width:
            scrollAxes === 'both' || scrollAxes === 'x' ? viewportOversize : 0,
        });

        return canvasRect;
      }
    };

    const updateCanvas = () => {
      let repaintRequired = false;

      const viewport = getViewportRect();
      this._viewportRect = viewport;
      const canvasRect = getCanvasRect(viewport);
      const canvasRectPrev = this._canvasRect;
      this._canvasRect = canvasRect;

      if (
        canvasRectPrev.width !== canvasRect.width ||
        canvasRectPrev.height !== canvasRect.height ||
        devicePixelRatio !== this._dpr
      ) {
        this._dpr = devicePixelRatio;

        // Canvas needs to change size, update its size
        canvas.style.width = `${canvasRect.width}px`;
        canvas.style.height = `${canvasRect.height}px`;
        this._canvasResizeListener?.(
          canvas,
          canvasRect.width,
          canvasRect.height,
        );
        repaintRequired = true;
      }

      if (
        canvasRectPrev.left !== canvasRect.left ||
        canvasRectPrev.top !== canvasRect.top
      ) {
        // Canvas needs to move, update the transform
        canvas.style.transform = `translate(${canvasRect.left}px, ${canvasRect.top}px)`;
        repaintRequired = true;
      }

      repaintRequired && this._layoutShiftListener?.(canvas, canvasRect);
    };

    containerElement.addEventListener('scroll', updateCanvas, {
      passive: true,
    });
    this._trash.defer(() =>
      containerElement.removeEventListener('scroll', updateCanvas),
    );

    // Resize observer callbacks are called once immediately after registration
    const resizeObserver = new ResizeObserver((_cb) => {
      updateCanvas();
    });

    resizeObserver.observe(containerElement);
    resizeObserver.observe(targetElement);
    this._trash.defer(() => {
      resizeObserver.disconnect();
    });

    // Ensures the canvas doesn't change the size of the target element
    targetElement.style.overflow = 'hidden';

    const canvas = document.createElement('canvas');
    canvas.style.position = 'absolute';
    targetElement.appendChild(canvas);
    this._trash.defer(() => {
      targetElement.removeChild(canvas);
    });

    this._canvasElement = canvas;
    this._targetElement = targetElement;
    const emptyRect = new Rect2D({
      left: 0,
      top: 0,
      bottom: 0,
      right: 0,
    });
    this._canvasRect = emptyRect;
    this._viewportRect = emptyRect;
    this._viewportLimits = emptyRect;
  }

  /**
   * Set the callback that gets called when the canvas element is moved or
   * resized, thus, invalidating the contents, and should be re-painted.
   *
   * @param cb The new callback.
   */
  setLayoutShiftListener(cb: LayoutShiftListener) {
    this._layoutShiftListener = cb;
  }

  /**
   * Set the callback that gets called when the canvas element is resized. This
   * might be a good opportunity to update the size of the canvas' draw buffer.
   *
   * @param cb The new callback.
   */
  setCanvasResizeListener(cb: CanvasResizeListener) {
    this._canvasResizeListener = cb;
  }

  /**
   * The floating canvas element.
   */
  get canvasElement(): HTMLCanvasElement {
    return this._canvasElement;
  }

  /**
   * The target element, i.e. the one passed to our constructor.
   */
  get targetElement(): HTMLElement {
    return this._targetElement;
  }

  /**
   * The size of the target element, aka the size of the virtual canvas.
   */
  get size(): Size2D {
    return {
      width: this._targetElement.clientWidth,
      height: this._targetElement.clientHeight,
    };
  }

  /**
   * Returns the rect of the floating canvas with respect to the target element.
   * This will need to be subtracted from any drawing operations to get the
   * right alignment within the virtual canvas.
   */
  get canvasRect(): Rect2D {
    return this._canvasRect;
  }

  /**
   * Returns the cached rect of the visible viewport with respect to the target
   * element. Unlike canvasRect, this does not include overdraw area.
   */
  get viewportRect(): Rect2D {
    return this._viewportRect;
  }

  /**
   * Stop listening to DOM events.
   */
  [Symbol.dispose]() {
    this._trash.dispose();
  }

  /**
   * Return true if a rect overlaps the floating canvas.
   * @param rect The rect to test.
   * @returns true if rect overlaps, false otherwise.
   */
  overlapsCanvas(rect: Bounds2D): boolean {
    const c = this._canvasRect;
    const y = rect.top < c.bottom && rect.bottom > c.top;
    const x = rect.left < c.right && rect.right > c.left;
    return x && y;
  }
}
