/* Copyright 2022 Mozilla Foundation
 *
 * 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.
 */

import { Util } from "./ui_utils.js";
import { AnnotationEditorType, AnnotationEditorParamsType } from '../constants';
import { PDFAnnotation } from "./pdf_annotation.js";
import { opacityToHex } from "./tools.js";

// The dimensions of the resizer is 15x15:
// https://searchfox.org/mozilla-central/rev/1ce190047b9556c3c10ab4de70a0e61d893e2954/toolkit/content/minimal-xul.css#136-137
// so each dimension must be greater than RESIZER_SIZE.
const RESIZER_SIZE = 16;

// Some dimensions aren't in percent and that leads to some errors
// when the page is zoomed (see #15571).
const TIME_TO_WAIT_BEFORE_FIXING_DIMS = 100;

/**
 * Basic draw editor in order to generate an Ink annotation.
 */
class InkEditor extends PDFAnnotation {

  static _defaultColor = null;

  static _defaultOpacity = 1;

  static _defaultThickness = 1;

  static _l10nPromise;

  static _type = "ink";

  static _zIndex = 1;

  constructor(params) {
    super({ ...params, name: "inkEditor" });

    this._aspectRatio = 0;

    this._zIndexForeground = InkEditor._zIndex++;

    this._baseHeight = 0;
  
    this._baseWidth = 0;
  
    this._boundCanvasPointermove = this.canvasPointermove.bind(this);
  
    this._boundCanvasPointerleave = this.canvasPointerleave.bind(this);
  
    this._boundCanvasPointerup = this.canvasPointerup.bind(this);
  
    this._boundCanvasPointerdown = this.canvasPointerdown.bind(this);
  
    this._disableEditing = false;
  
    this._isCanvasInitialized = false;
  
    this._lastPoint = null;
  
    this._observer = null;
  
    this._realWidth = 0;
  
    this._realHeight = 0;
  
    this._requestFrameCallback = null;

    this.color = params.color || null;
    this.thickness = params.thickness || null;
    this.opacity = params.opacity || null;
    this.paths = [];
    this.bezierPath2D = [];
    this.currentPath = [];
    this.arrowPoints = [];
    this.scaleFactor = 1;
    this.translationX = this.translationY = 0;
    this.x = 0;
    this.y = 0;
  }

  static updateDefaultParams(type, value) {
    switch (type) {
      case AnnotationEditorParamsType.INK_THICKNESS:
        InkEditor._defaultThickness = value;
        break;
      case AnnotationEditorParamsType.INK_COLOR:
        InkEditor._defaultColor = value;
        break;
      case AnnotationEditorParamsType.INK_OPACITY:
        InkEditor._defaultOpacity = value / 100;
        break;
    }
  }

  /** @inheritdoc */
  updateParams(type, value) {
    switch (type) {
      case AnnotationEditorParamsType.INK_THICKNESS:
        this._updateThickness(value);
        break;
      case AnnotationEditorParamsType.INK_COLOR:
        this._updateColor(value);
        break;
      case AnnotationEditorParamsType.INK_OPACITY:
        this._updateOpacity(value);
        break;
    }
  }

  static get defaultPropertiesToUpdate() {
    return [
      [AnnotationEditorParamsType.INK_THICKNESS, InkEditor._defaultThickness],
      [
        AnnotationEditorParamsType.INK_COLOR,
        InkEditor._defaultColor || PDFAnnotation._defaultLineColor,
      ],
      [
        AnnotationEditorParamsType.INK_OPACITY,
        Math.round(InkEditor._defaultOpacity * 100),
      ],
    ];
  }

  /** @inheritdoc */
  get propertiesToUpdate() {
    return [
      [
        AnnotationEditorParamsType.INK_THICKNESS,
        this.thickness || InkEditor._defaultThickness,
      ],
      [
        AnnotationEditorParamsType.INK_COLOR,
        this.color ||
          InkEditor._defaultColor ||
          PDFAnnotation._defaultLineColor,
      ],
      [
        AnnotationEditorParamsType.INK_OPACITY,
        Math.round(100 * (this.opacity ?? InkEditor._defaultOpacity)),
      ],
    ];
  }

  /**
   * Update the thickness and make this action undoable.
   * @param {number} thickness
   */
  _updateThickness(thickness) {
    this.thickness = thickness;
    this.#fitToContent();
  }

  /**
   * Update the color and make this action undoable.
   * @param {string} color
   */
  _updateColor(color) {
    const savedColor = this.color;
    this.parent.addCommands({
      cmd: () => {
        this.color = color;
        this.#redraw();
      },
      undo: () => {
        this.color = savedColor;
        this.#redraw();
      },
      mustExec: true,
      type: AnnotationEditorParamsType.INK_COLOR,
      overwriteIfSameType: true,
      keepUndo: true,
    });
  }

  /**
   * Update the opacity and make this action undoable.
   * @param {number} opacity
   */
  _updateOpacity(opacity) {
    opacity /= 100;
    const savedOpacity = this.opacity;
    this.parent.addCommands({
      cmd: () => {
        this.opacity = opacity;
        this.#redraw();
      },
      undo: () => {
        this.opacity = savedOpacity;
        this.#redraw();
      },
      mustExec: true,
      type: AnnotationEditorParamsType.INK_OPACITY,
      overwriteIfSameType: true,
      keepUndo: true,
    });
  }

  /** @inheritdoc */
  rebuild() {
    super.rebuild();
    if (this.div === null) {
      return;
    }

    if (!this.canvas) {
      this.#createCanvas();
      // this.#createObserver();
    }

    if (!this.isAttachedToDOM) {
      // At some point this editor was removed and we're rebuilding it,
      // hence we must add it to its parent.
      this.parent.add(this);
      this.#setCanvasDims();
    }
    this.#fitToContent();
  }

  /** @inheritdoc */
  remove() {
    if (this.canvas === null) {
      return;
    }

    if (!this.isEmpty()) {
      this.commit();
    }

    // Destroy the canvas.
    this.canvas.width = this.canvas.height = 0;
    this.canvas.remove();
    this.canvas = null;

    this._observer.disconnect();
    this._observer = null;

    super.remove();
  }

  /** @inheritdoc */
  enableEditMode() {
    if (this._disableEditing || this.canvas === null) {
      return;
    }

    super.enableEditMode();
    this.div.draggable = false;
    this.canvas.addEventListener("pointerdown", this._boundCanvasPointerdown);
    this.canvas.addEventListener("pointerup", this._boundCanvasPointerup);
  }

  /** @inheritdoc */
  disableEditMode() {
    if (!this.isInEditMode() || this.canvas === null) {
      return;
    }

    super.disableEditMode();
    this.div.draggable = !this.isEmpty();
    this.div.classList.remove("editing");
    this.canvas.removeEventListener("pointerup", this._boundCanvasPointerup);
  }

  /** @inheritdoc */
  onceAdded() {
    this.div.draggable = !this.isEmpty();
  }

  /** @inheritdoc */
  isEmpty() {
    return (
      !this.paths || this.paths.length < 2
    );
  }

  #getInitialBBox() {
    const { width, height, rotation } = this.parent.viewport;
    switch (rotation) {
      case 90:
        return [0, width, width, height];
      case 180:
        return [width, height, width, height];
      case 270:
        return [height, 0, width, height];
      default:
        return [0, 0, width, height];
    }
  }

  /**
   * Set line styles.
   */
  #setStroke() {
    this.ctx.lineWidth =
      (this.thickness * this.parent.scaleFactor) / this.scaleFactor;
    this.ctx.lineCap = "round";
    this.ctx.lineJoin = "round";
    this.ctx.miterLimit = 10;
    this.ctx.strokeStyle = `${this.color}${opacityToHex(this.opacity)}`;
  }

  /**
   * Start to draw on the canvas.
   * @param {number} x
   * @param {number} y
   */
  #startDrawing(x, y) {
    this.isEditing = true;
    if (!this._isCanvasInitialized) {
      this._isCanvasInitialized = true;
      this.#setCanvasDims();
      this.thickness ||= InkEditor._defaultThickness;
      this.color ||=
        InkEditor._defaultColor || PDFAnnotation._defaultLineColor;
      this.opacity ??= InkEditor._defaultOpacity;
    }
    this.currentPath[0] = [x, y];
    this._lastPoint = null;
    this.#setStroke();

    this._requestFrameCallback = () => {
      if (!this._requestFrameCallback) {
        return;
      }

      if (this._lastPoint) {
        this.drawLine(this.currentPath[0], this._lastPoint)
        this._lastPoint = null;
      }

      window.requestAnimationFrame(this._requestFrameCallback);
    };
    window.requestAnimationFrame(this._requestFrameCallback);
  }

  /**
   * Draw on the canvas.
   * @param {number} x
   * @param {number} y
   */
  #draw(x, y) {
    const [lastX, lastY] = this.currentPath.at(-1);
    if (x === lastX && y === lastY) {
      return;
    }
    this.currentPath[1] = [x, y];
    this._lastPoint = [x, y];
  }

  /**
   * Stop to draw on the canvas.
   * @param {number} x
   * @param {number} y
   */
  #stopDrawing(x, y) {
    this.ctx.closePath();
    this._requestFrameCallback = null;

    x = Math.min(Math.max(x, 0), this.canvas.width);
    y = Math.min(Math.max(y, 0), this.canvas.height);

    const [lastX, lastY] = this.currentPath.at(-1);
    if (x !== lastX || y !== lastY) {
      this.currentPath[1] = [x, y];
    }

    if (this.currentPath.length === 0) return

    this.paths = [...this.currentPath];
    this.rebuild();
  }

  drawLine(firstPoint, lastPoint) {
    const x = firstPoint[0] - lastPoint[0];
    const y = firstPoint[1] - lastPoint[1];
    const angle = Math.atan(y/x);
    const middle = [(firstPoint[0] + lastPoint[0])/2, (firstPoint[1] + lastPoint[1])/2]
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.beginPath();
    this.ctx.moveTo(...firstPoint);
    this.ctx.lineTo(...lastPoint);
    this.ctx.stroke();
    this.drawStartLineArrow(...firstPoint, ...lastPoint);
    this.drawEndLineArrow(...firstPoint, ...lastPoint);
    if (this._move) {
      this.drawCircle(firstPoint, lastPoint, angle)
    }
    this.ctx.save();
    this.ctx.translate(...middle);
    this.ctx.beginPath();
    this.ctx.rotate(angle);
    const distance = Math.round(Math.sqrt(x*x + y*y) * 10) / 10
    this.ctx.fillStyle = "blue";
    this.ctx.font="12px Arial";
    const width = this.ctx.measureText(distance + ' pt').width
    this.ctx.fillText(distance + ' pt', -width * Math.cos(angle)/2, 0);
    this.ctx.restore();
    this.ctx.save();
    this.parent._uiManager._eventBus.dispatch('distanceChanged', {
      distance
    })
  }

  drawCircle(firstPoint, lastPoint) {
    const x = firstPoint[0] - lastPoint[0];
    const y = firstPoint[1] - lastPoint[1];
    const angle = Math.atan(y/x);
    const startPointX = firstPoint[0] + 10 * Math.cos(angle)
    const startPointY = firstPoint[1] + 10 * Math.sin(angle)
    const ctx = this.ctx
    const fillColor = '#0097ff'
    ctx.fillStyle = fillColor;
    ctx.beginPath();
    ctx.arc(startPointX, startPointY, 6, 0, 2 * Math.PI, true);
    ctx.fill();
    ctx.stroke();

    const lastPointX = lastPoint[0] - 10 * Math.cos(angle)
    const lastPointY = lastPoint[1] - 10 * Math.sin(angle)
    ctx.fillStyle = fillColor;
    ctx.beginPath();
    ctx.arc(lastPointX, lastPointY, 6, 0, 2 * Math.PI, true);
    ctx.fill();
    ctx.stroke();
    this._dragStartPoint = [startPointX, startPointY]
    this._dragEndPoint = [lastPointX, lastPointY]
  }

  /**
   * Redraw all the paths.
   */
  #redraw() {
    if (this.isEmpty()) {
      this.#updateTransform();
      return;
    }
    this.#setStroke();

    const { canvas, ctx } = this;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    this.#updateTransform();
    this.drawLine(this.currentPath[0], this.currentPath[1])
  }

  drawStartLineArrow(fromX, fromY, toX, toY) {
    const headlen = 10;//自定义箭头线的长度
    const theta = 30;//自定义箭头线与直线的夹角，个人觉得45°刚刚好
    let arrowX, arrowY;//箭头线终点坐标
    // 计算各角度和对应的箭头终点坐标
    const angle = Math.atan2(toY - fromY, toX - fromX) * 180 / Math.PI;
    const angle1 = (angle + theta) * Math.PI / 180;
    const angle2 = (angle - theta) * Math.PI / 180;
    const topX = headlen * Math.cos(angle1);
    const topY = headlen * Math.sin(angle1);
    const botX = headlen * Math.cos(angle2);
    const botY = headlen * Math.sin(angle2);

    arrowX = fromX + topX;
    arrowY = fromY + topY;
    
    this.arrowPoints[0] = [arrowX, arrowY]
    //画上边箭头线
    this.ctx.moveTo(arrowX, arrowY);
    this.ctx.lineTo(fromX, fromY);

    arrowX = fromX + botX;
    arrowY = fromY + botY;

    this.arrowPoints[1] = [arrowX, arrowY]
    //画下边箭头线
    this.ctx.lineTo(arrowX, arrowY);
    
    this.ctx.stroke();
  }

  drawEndLineArrow(fromX, fromY, toX, toY) {
    const headlen = 10;//自定义箭头线的长度
    const theta = 30;//自定义箭头线与直线的夹角，个人觉得45°刚刚好
    let arrowX, arrowY;//箭头线终点坐标
    // 计算各角度和对应的箭头终点坐标
    const angle = Math.atan2(fromY - toY, fromX - toX) * 180 / Math.PI;
    const angle1 = (angle + theta) * Math.PI / 180;
    const angle2 = (angle - theta) * Math.PI / 180;
    const topX = headlen * Math.cos(angle1);
    const topY = headlen * Math.sin(angle1);
    const botX = headlen * Math.cos(angle2);
    const botY = headlen * Math.sin(angle2);

    arrowX = toX + topX;
    arrowY = toY + topY;

    this.arrowPoints[2] = [arrowX, arrowY]
    //画上边箭头线
    this.ctx.moveTo(arrowX, arrowY);
    this.ctx.lineTo(toX, toY);

    arrowX = toX + botX;
    arrowY = toY + botY;

    this.arrowPoints[3] = [arrowX, arrowY]
    //画下边箭头线
    this.ctx.lineTo(arrowX, arrowY);
    
    this.ctx.stroke();
  }

  /**
   * Commit the curves we have in this editor.
   */
  commit() {
    if (this._disableEditing) {
      return;
    }

    super.commit();

    this.isEditing = false;
    this.disableEditMode();

    // This editor must be on top of the main ink editor.
    this.setInForeground();

    this._disableEditing = true;
    this.div.classList.add("disabled");

    this.#fitToContent(/* firstTime = */ true);

    this.parent.addInkEditorIfNeeded(/* isCommitting = */ true);

    // When commiting, the position of this editor is changed, hence we must
    // move it to the right position in the DOM.
    this.parent.moveEditorInDOM(this);
    // After the div has been moved in the DOM, the focus may have been stolen
    // by document.body, hence we just keep it here.
    this.div.focus();
  }

  /** @inheritdoc */
  focusin(event) {
    super.focusin(event);
    this.enableEditMode();
  }

  /**
   * onpointerdown callback for the canvas we're drawing on.
   * @param {PointerEvent} event
   */
  canvasPointerdown(event) {
    if (event.button !== 0 || !this.isInEditMode() || this._disableEditing) {
      return;
    }

    // We want to draw on top of any other editors.
    // Since it's the last child, there's no need to give it a higher z-index.
    this.setInForeground();

    if (event.type !== "mouse") {
      this.div.focus();
    }

    event.stopPropagation();

    this.canvas.addEventListener("pointerleave", this._boundCanvasPointerleave);
    this.canvas.addEventListener("pointermove", this._boundCanvasPointermove);

    this.#startDrawing(event.offsetX, event.offsetY);
  }

  /**
   * onpointermove callback for the canvas we're drawing on.
   * @param {PointerEvent} event
   */
  canvasPointermove(event) {
    event.stopPropagation();
    this.#draw(event.offsetX, event.offsetY);
  }

  /**
   * onpointerup callback for the canvas we're drawing on.
   * @param {PointerEvent} event
   */
  canvasPointerup(event) {
    if (event.button !== 0) {
      return;
    }
    if (this.isInEditMode() && this.currentPath.length !== 0) {
      event.stopPropagation();
      this.#endDrawing(event);

      // Since the ink editor covers all of the page and we want to be able
      // to select another editor, we just put this one in the background.
      this.setInBackground();
    }
    
    if (!this.isAttachedToDOM || this.currentPath.length < 2) {
      return;
    }

    // In case of focusout, the relatedTarget is the element which
    // is grabbing the focus.
    // So if the related target is an element under the div for this
    // editor, then the editor isn't unactive.
    const target = event.relatedTarget;
    if (target?.closest(`#${this.id}`)) {
      return;
    }

    event.preventDefault();

    if (!this.parent.isMultipleSelection) {
      this.commitOrRemove();
    }
    this.parent.unselect(this);
  }

  /**
   * onpointerleave callback for the canvas we're drawing on.
   * @param {PointerEvent} event
   */
  canvasPointerleave(event) {
    this.#endDrawing(event);
    this.setInBackground();
  }

  /**
   * 设置zIndex在其他之上
   */
  setInForeground() {
    this.div.style.zIndex = this._zIndexForeground;
  }

  /**
   * End the drawing.
   * @param {PointerEvent} event
   */
  #endDrawing(event) {
    this.#stopDrawing(event.offsetX, event.offsetY);

    this.canvas.removeEventListener(
      "pointerleave",
      this._boundCanvasPointerleave
    );
    this.canvas.removeEventListener(
      "pointermove",
      this._boundCanvasPointermove
    );

    this.parent.addToAnnotationStorage(this);
  }

  /**
   * Create the canvas element.
   */
  #createCanvas() {
    this.canvas = document.createElement("canvas");
    this.canvas.width = this.canvas.height = 0;
    this.canvas.className = "inkEditorCanvas";

    this.div.append(this.canvas);
    this.ctx = this.canvas.getContext("2d");
    this.textCtx = this.canvas.getContext("2d");
  }

  /**
   * Create the resize observer.
   */
  #createObserver() {
    let timeoutId = null;
    this._observer = new ResizeObserver(entries => {
      const rect = entries[0].contentRect;
      if (rect.width && rect.height) {
        // Workaround for:
        // https://bugzilla.mozilla.org/show_bug.cgi?id=1795536
        if (timeoutId !== null) {
          clearTimeout(timeoutId);
        }
        timeoutId = setTimeout(() => {
          this.fixDims();
          timeoutId = null;
        }, TIME_TO_WAIT_BEFORE_FIXING_DIMS);

        this.setDimensions(rect.width, rect.height);
      }
    });
    this._observer.observe(this.div);
  }

  /** @inheritdoc */
  render() {
    if (this.div) {
      return this.div;
    }

    let baseX, baseY;
    if (this.width) {
      baseX = this.x;
      baseY = this.y;
    }

    super.render();

    const [x, y, w, h] = this.#getInitialBBox();
    this.setAt(x, y, 0, 0);
    this.setDims(w, h);

    this.#createCanvas();

    if (this.width) {
      // This editor was created in using copy (ctrl+c).
      const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
      this.setAt(
        baseX * parentWidth,
        baseY * parentHeight,
        this.width * parentWidth,
        this.height * parentHeight
      );
      this._isCanvasInitialized = true;
      this.#setCanvasDims();
      this.setDims(this.width * parentWidth, this.height * parentHeight);
      this.#redraw();
      this.#setMinDims();
      this.div.classList.add("disabled");
    } else {
      this.div.classList.add("editing");
      this.enableEditMode();
    }

    this.#createObserver();

    return this.div;
  }

  #setCanvasDims() {
    if (!this._isCanvasInitialized) {
      return;
    }
    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
    this.canvas.width = Math.ceil(this.width * parentWidth);
    this.canvas.height = Math.ceil(this.height * parentHeight);
    this.#updateTransform();
  }

  /**
   * When the dimensions of the div change the inner canvas must
   * renew its dimensions, hence it must redraw its own contents.
   * @param {number} width - the new width of the div
   * @param {number} height - the new height of the div
   * @returns
   */
  setDimensions(width, height) {
    const roundedWidth = Math.round(width);
    const roundedHeight = Math.round(height);
    if (
      this._realWidth === roundedWidth &&
      this._realHeight === roundedHeight
    ) {
      return;
    }

    this._realWidth = roundedWidth;
    this._realHeight = roundedHeight;

    this.canvas.style.visibility = "hidden";

    if (
      this._aspectRatio &&
      Math.abs(this._aspectRatio - width / height) > 1e-2
    ) {
      height = Math.ceil(width / this._aspectRatio);
      this.setDims(width, height);
    }

    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
    this.width = width / parentWidth;
    this.height = height / parentHeight;

    if (this._disableEditing) {
      this.#setScaleFactor(width, height);
    }

    this.#setCanvasDims();
    this.#redraw();

    this.canvas.style.visibility = "visible";
  }

  #setScaleFactor(width, height) {
    const padding = this.#getPadding();
    const scaleFactorW = (width - padding) / this._baseWidth;
    const scaleFactorH = (height - padding) / this._baseHeight;
    this.scaleFactor = Math.min(scaleFactorW, scaleFactorH);
  }

  /**
   * Update the canvas transform.
   */
  #updateTransform() {
    const padding = this.#getPadding() / 2;
    this.ctx.setTransform(
      this.scaleFactor,
      0,
      0,
      this.scaleFactor,
      this.translationX * this.scaleFactor + padding,
      this.translationY * this.scaleFactor + padding
    );
  }

  /**
   * Convert the output of fitCurve in some Path2D.
   * @param {Arra<Array<number>} bezier
   * @returns {Path2D}
   */
  static #buildPath2D(bezier) {
    const path2D = new Path2D();
    for (let i = 0, ii = bezier.length; i < ii; i++) {
      const [first, control1, control2, second] = bezier[i];
      if (i === 0) {
        path2D.moveTo(...first);
      }
      path2D.bezierCurveTo(
        control1[0],
        control1[1],
        control2[0],
        control2[1],
        second[0],
        second[1]
      );
    }
    return path2D;
  }

  /**
   * Transform and serialize the paths.
   * @param {number} s - scale factor
   * @param {number} tx - abscissa of the translation
   * @param {number} ty - ordinate of the translation
   * @param {number} h - height of the bounding box
   */
  #serializePaths(s, tx, ty, h) {
    const NUMBER_OF_POINTS_ON_BEZIER_CURVE = 4;
    const paths = [];
    const padding = this.thickness / 2;
    let buffer, points;

    for (const bezier of this.paths) {
      buffer = [];
      points = [];
      for (let i = 0, ii = bezier.length; i < ii; i++) {
        const [first, control1, control2, second] = bezier[i];
        const p10 = s * (first[0] + tx) + padding;
        const p11 = h - s * (first[1] + ty) - padding;
        const p20 = s * (control1[0] + tx) + padding;
        const p21 = h - s * (control1[1] + ty) - padding;
        const p30 = s * (control2[0] + tx) + padding;
        const p31 = h - s * (control2[1] + ty) - padding;
        const p40 = s * (second[0] + tx) + padding;
        const p41 = h - s * (second[1] + ty) - padding;

        if (i === 0) {
          buffer.push(p10, p11);
          points.push(p10, p11);
        }
        buffer.push(p20, p21, p30, p31, p40, p41);
        this.#extractPointsOnBezier(
          p10,
          p11,
          p20,
          p21,
          p30,
          p31,
          p40,
          p41,
          NUMBER_OF_POINTS_ON_BEZIER_CURVE,
          points
        );
      }
      paths.push({ bezier: buffer, points });
    }

    return paths;
  }

  /**
   * Extract n-1 points from the cubic Bezier curve.
   * @param {number} p10
   * @param {number} p11
   * @param {number} p20
   * @param {number} p21
   * @param {number} p30
   * @param {number} p31
   * @param {number} p40
   * @param {number} p41
   * @param {number} n
   * @param {Array<number>} points
   * @returns {undefined}
   */
  #extractPointsOnBezier(p10, p11, p20, p21, p30, p31, p40, p41, n, points) {
    // If we can save few points thanks to the flatness we must do it.
    if (this.#isAlmostFlat(p10, p11, p20, p21, p30, p31, p40, p41)) {
      points.push(p40, p41);
      return;
    }

    // Apply the de Casteljau's algorithm in order to get n points belonging
    // to the Bezier's curve:
    // https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm

    // The first point is the last point of the previous Bezier curve
    // so no need to push the first point.
    for (let i = 1; i < n - 1; i++) {
      const t = i / n;
      const mt = 1 - t;

      let q10 = t * p10 + mt * p20;
      let q11 = t * p11 + mt * p21;

      let q20 = t * p20 + mt * p30;
      let q21 = t * p21 + mt * p31;

      const q30 = t * p30 + mt * p40;
      const q31 = t * p31 + mt * p41;

      q10 = t * q10 + mt * q20;
      q11 = t * q11 + mt * q21;

      q20 = t * q20 + mt * q30;
      q21 = t * q21 + mt * q31;

      q10 = t * q10 + mt * q20;
      q11 = t * q11 + mt * q21;

      points.push(q10, q11);
    }

    points.push(p40, p41);
  }

  /**
   * Check if a cubic Bezier curve is almost flat.
   * @param {number} p10
   * @param {number} p11
   * @param {number} p20
   * @param {number} p21
   * @param {number} p30
   * @param {number} p31
   * @param {number} p40
   * @param {number} p41
   * @returns {boolean}
   */
  #isAlmostFlat(p10, p11, p20, p21, p30, p31, p40, p41) {
    // For reference:
    //   https://jeremykun.com/tag/bezier-curves/
    const tol = 10;

    const ax = (3 * p20 - 2 * p10 - p40) ** 2;
    const ay = (3 * p21 - 2 * p11 - p41) ** 2;
    const bx = (3 * p30 - p10 - 2 * p40) ** 2;
    const by = (3 * p31 - p11 - 2 * p41) ** 2;

    return Math.max(ax, bx) + Math.max(ay, by) <= tol;
  }

  /**
   * Get the bounding box containing all the paths.
   * @returns {Array<number>}
   */
  #getBbox() {
    const points = [...this.currentPath, ...this.arrowPoints];
    let xMin = Infinity;
    let xMax = -Infinity;
    let yMin = Infinity;
    let yMax = -Infinity;
    for (const [x, y] of points) {
      xMin = Math.min(x, xMin);
      yMin = Math.min(y, yMin);
      xMax = Math.max(x, xMax);
      yMax = Math.max(y, yMax);
    }
    return [xMin, yMin, xMax, yMax];
  }

  /**
   * The bounding box is computed with null thickness, so we must take
   * it into account for the display.
   * It corresponds to the total padding, hence it should be divided by 2
   * in order to have left/right paddings.
   * @returns {number}
   */
  #getPadding() {
    return this._disableEditing
      ? Math.ceil(this.thickness * this.parent.scaleFactor)
      : 0;
  }

  /**
   * Set the div position and dimensions in order to fit to
   * the bounding box of the contents.
   * @returns {undefined}
   */
  #fitToContent(firstTime = false) {
    if (this.isEmpty()) {
      return;
    }

    if (!this._disableEditing) {
      this.#redraw();
      return;
    }

    const bbox = this.#getBbox();
    const padding = this.#getPadding();
    this._baseWidth = Math.max(RESIZER_SIZE, bbox[2] - bbox[0]);
    this._baseHeight = Math.max(RESIZER_SIZE, bbox[3] - bbox[1]);

    const width = Math.ceil(padding + this._baseWidth * this.scaleFactor);
    const height = Math.ceil(padding + this._baseHeight * this.scaleFactor);

    const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions;
    this.width = width / parentWidth;
    this.height = height / parentHeight;

    this._aspectRatio = width / height;
    this.#setMinDims();

    const prevTranslationX = this.translationX;
    const prevTranslationY = this.translationY;

    this.translationX = -bbox[0];
    this.translationY = -bbox[1];
    this.#setCanvasDims();
    this.#redraw();

    this._realWidth = width;
    this._realHeight = height;

    this.setDims(width, height);
    const unscaledPadding = firstTime ? padding / this.scaleFactor / 2 : 0;
    this.translate(
      prevTranslationX - this.translationX - unscaledPadding,
      prevTranslationY - this.translationY - unscaledPadding
    );
  }

  #setMinDims() {
    const { style } = this.div;
    if (this._aspectRatio >= 1) {
      style.minHeight = `${RESIZER_SIZE}px`;
      style.minWidth = `${Math.round(this._aspectRatio * RESIZER_SIZE)}px`;
    } else {
      style.minWidth = `${RESIZER_SIZE}px`;
      style.minHeight = `${Math.round(RESIZER_SIZE / this._aspectRatio)}px`;
    }
  }

  /** @inheritdoc */
  static deserialize(data, parent) {
    const editor = super.deserialize(data, parent);

    editor.thickness = data.thickness;
    editor.color = Util.makeHexColor(...data.color);
    editor.opacity = data.opacity;

    const [pageWidth, pageHeight] = parent.pageDimensions;
    const width = editor.width * pageWidth;
    const height = editor.height * pageHeight;
    const scaleFactor = parent.scaleFactor;
    const padding = data.thickness / 2;

    editor._aspectRatio = width / height;
    editor._disableEditing = true;
    editor._realWidth = Math.round(width);
    editor._realHeight = Math.round(height);

    for (const { bezier } of data.paths) {
      const path = [];
      editor.paths.push(path);
      let p0 = scaleFactor * (bezier[0] - padding);
      let p1 = scaleFactor * (height - bezier[1] - padding);
      for (let i = 2, ii = bezier.length; i < ii; i += 6) {
        const p10 = scaleFactor * (bezier[i] - padding);
        const p11 = scaleFactor * (height - bezier[i + 1] - padding);
        const p20 = scaleFactor * (bezier[i + 2] - padding);
        const p21 = scaleFactor * (height - bezier[i + 3] - padding);
        const p30 = scaleFactor * (bezier[i + 4] - padding);
        const p31 = scaleFactor * (height - bezier[i + 5] - padding);
        path.push([
          [p0, p1],
          [p10, p11],
          [p20, p21],
          [p30, p31],
        ]);
        p0 = p30;
        p1 = p31;
      }
      const path2D = this.#buildPath2D(path);
      editor.bezierPath2D.push(path2D);
    }

    const bbox = editor.#getBbox();
    editor._baseWidth = Math.max(RESIZER_SIZE, bbox[2] - bbox[0]);
    editor._baseHeight = Math.max(RESIZER_SIZE, bbox[3] - bbox[1]);
    editor.#setScaleFactor(width, height);

    return editor;
  }

  /** @inheritdoc */
  serialize() {
    if (this.isEmpty()) {
      return null;
    }

    const rect = this.getRect(0, 0);
    const height =
      this.rotation % 180 === 0 ? rect[3] - rect[1] : rect[2] - rect[0];

    const color = PDFAnnotation._defaultLineColor;

    return {
      annotationType: AnnotationEditorType.INK,
      color,
      thickness: this.thickness,
      opacity: this.opacity,
      paths: this.#serializePaths(
        this.scaleFactor / this.parent.scaleFactor,
        this.translationX,
        this.translationY,
        height
      ),
      pageIndex: this.parent.pageIndex,
      rect,
      rotation: this.rotation,
    };
  }
}

export { InkEditor };
