import type { XY } from '../../../Point';
import { Point } from '../../../Point';
import { halfPI, twoMathPi } from '../../../constants';
import type { TRadian } from '../../../typedefs';
import { degreesToRadians } from '../radiansDegreesConversion';
import {
  calcAngleBetweenVectors,
  calcVectorRotation,
  crossProduct,
  getOrthonormalVector,
  getUnitVector,
  isBetweenVectors,
  magnitude,
  rotateVector,
} from '../vectors';
import { StrokeProjectionsBase } from './StrokeProjectionsBase';
import type { TProjection, TProjectStrokeOnPointsOptions } from './types';

const zeroVector = new Point();

/**
 * class in charge of finding projections for each type of line join
 * @see {@link [Closed path projections at #8344](https://github.com/fabricjs/fabric.js/pull/8344#2-closed-path)}
 *
 * - MDN:
 *   - https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin
 *   - https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-linejoin
 * - Spec: https://svgwg.org/svg2-draft/painting.html#StrokeLinejoinProperty
 * - Playground to understand how the line joins works: https://hypertolosana.github.io/efficient-webgl-stroking/index.html
 * - View the calculated projections for each of the control points: https://codesandbox.io/s/project-stroke-points-with-context-to-trace-b8jc4j?file=/src/index.js
 *
 */
export class StrokeLineJoinProjections extends StrokeProjectionsBase {
  /**
   * The point being projected (the angle ∠BAC)
   */
  declare A: Point;
  /**
   * The point before A
   */
  declare B: Point;
  /**
   * The point after A
   */
  declare C: Point;
  /**
   * The AB vector
   */
  AB: Point;
  /**
   * The AC vector
   */
  AC: Point;
  /**
   * The angle of A (∠BAC)
   */
  alpha: TRadian;
  /**
   * The bisector of A (∠BAC)
   */
  bisector: Point;

  static getOrthogonalRotationFactor(vector1: Point, vector2?: Point) {
    const angle = vector2
      ? calcAngleBetweenVectors(vector1, vector2)
      : calcVectorRotation(vector1);
    return Math.abs(angle) < halfPI ? -1 : 1;
  }

  constructor(A: XY, B: XY, C: XY, options: TProjectStrokeOnPointsOptions) {
    super(options);
    this.A = new Point(A);
    this.B = new Point(B);
    this.C = new Point(C);
    this.AB = this.createSideVector(this.A, this.B);
    this.AC = this.createSideVector(this.A, this.C);
    this.alpha = calcAngleBetweenVectors(this.AB, this.AC);
    this.bisector = getUnitVector(
      // if AC is also the zero vector nothing will be projected
      // in that case the next point will handle the projection
      rotateVector(this.AB.eq(zeroVector) ? this.AC : this.AB, this.alpha / 2),
    );
  }

  calcOrthogonalProjection(
    from: Point,
    to: Point,
    magnitude: number = this.strokeProjectionMagnitude,
  ) {
    const vector = this.createSideVector(from, to);
    const orthogonalProjection = getOrthonormalVector(vector);
    const correctSide = StrokeLineJoinProjections.getOrthogonalRotationFactor(
      orthogonalProjection,
      this.bisector,
    );
    return this.scaleUnitVector(orthogonalProjection, magnitude * correctSide);
  }

  /**
   * BEVEL
   * Calculation: the projection points are formed by the vector orthogonal to the vertex.
   *
   * @see https://github.com/fabricjs/fabric.js/pull/8344#2-2-bevel
   */
  projectBevel() {
    const projections: Point[] = [];
    // if `alpha` equals 0 or 2*PI, the projections are the same for `B` and `C`
    (this.alpha % twoMathPi === 0 ? [this.B] : [this.B, this.C]).forEach(
      (to) => {
        projections.push(this.projectOrthogonally(this.A, to));
        projections.push(
          this.projectOrthogonally(this.A, to, -this.strokeProjectionMagnitude),
        );
      },
    );
    return projections;
  }

  /**
   * MITER
   * Calculation: the corner is formed by extending the outer edges of the stroke
   * at the tangents of the path segments until they intersect.
   *
   * @see https://github.com/fabricjs/fabric.js/pull/8344#2-1-miter
   */
  projectMiter() {
    const projections: Point[] = [],
      alpha = Math.abs(this.alpha),
      hypotUnitScalar = 1 / Math.sin(alpha / 2),
      miterVector = this.scaleUnitVector(
        this.bisector,
        -this.strokeProjectionMagnitude * hypotUnitScalar,
      );

    // When two line segments meet at a sharp angle, it is possible for the join to extend,
    // far beyond the thickness of the line stroking the path. The stroke-miterlimit imposes
    // a limit on the extent of the line join.
    // MDN: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-miterlimit
    // When the stroke is uniform, scaling changes the arrangement of points, this changes the miter-limit
    const strokeMiterLimit = this.options.strokeUniform
      ? magnitude(
          this.scaleUnitVector(this.bisector, this.options.strokeMiterLimit),
        )
      : this.options.strokeMiterLimit;

    if (
      magnitude(miterVector) / this.strokeProjectionMagnitude <=
      strokeMiterLimit
    ) {
      projections.push(this.applySkew(this.A.add(miterVector)));
    }
    /* when the miter-limit is reached, the stroke line join becomes of type bevel.
      We always need two orthogonal projections which are basically bevel-type projections,
      so regardless of whether the miter-limit was reached or not, we include these projections.
    */
    projections.push(...this.projectBevel());

    return projections;
  }

  /**
   * ROUND (without skew)
   * Calculation: the projections are the two vectors parallel to X and Y axes
   *
   * @see https://github.com/fabricjs/fabric.js/pull/8344#2-3-1-round-without-skew
   */
  private projectRoundNoSkew(startCircle: Point, endCircle: Point) {
    const projections: Point[] = [],
      // correctSide is used to only consider projecting for the outer side
      correctSide = new Point(
        StrokeLineJoinProjections.getOrthogonalRotationFactor(this.bisector),
        StrokeLineJoinProjections.getOrthogonalRotationFactor(
          new Point(this.bisector.y, this.bisector.x),
        ),
      ),
      radiusOnAxisX = new Point(1, 0)
        .scalarMultiply(this.strokeProjectionMagnitude)
        .multiply(this.strokeUniformScalar)
        .multiply(correctSide),
      radiusOnAxisY = new Point(0, 1)
        .scalarMultiply(this.strokeProjectionMagnitude)
        .multiply(this.strokeUniformScalar)
        .multiply(correctSide);

    [radiusOnAxisX, radiusOnAxisY].forEach((vector) => {
      if (isBetweenVectors(vector, startCircle, endCircle)) {
        projections.push(this.A.add(vector));
      }
    });
    return projections;
  }

  /**
   * ROUND (with skew)
   * Calculation: the projections are the points furthest from the vertex in
   * the direction of the X and Y axes after distortion.
   *
   * @see https://github.com/fabricjs/fabric.js/pull/8344#2-3-2-round-skew
   */
  private projectRoundWithSkew(startCircle: Point, endCircle: Point) {
    const projections: Point[] = [];

    const { skewX, skewY, scaleX, scaleY, strokeUniform } = this.options,
      shearing = new Point(
        Math.tan(degreesToRadians(skewX)),
        Math.tan(degreesToRadians(skewY)),
      );
    // The points furthest from the vertex in the direction of the X and Y axes after distortion
    const circleRadius = this.strokeProjectionMagnitude,
      newY = strokeUniform
        ? circleRadius /
          scaleY /
          Math.sqrt(1 / scaleY ** 2 + (1 / scaleX ** 2) * shearing.y ** 2)
        : circleRadius / Math.sqrt(1 + shearing.y ** 2),
      furthestY = new Point(
        // Safe guard due to floating point precision. In some situations the square root
        // was returning NaN because of a negative number close to zero.
        Math.sqrt(Math.max(circleRadius ** 2 - newY ** 2, 0)),
        newY,
      ),
      newX = strokeUniform
        ? circleRadius /
          Math.sqrt(
            1 +
              (shearing.x ** 2 * (1 / scaleY) ** 2) /
                (1 / scaleX + (1 / scaleX) * shearing.x * shearing.y) ** 2,
          )
        : circleRadius /
          Math.sqrt(1 + shearing.x ** 2 / (1 + shearing.x * shearing.y) ** 2),
      furthestX = new Point(
        newX,
        Math.sqrt(Math.max(circleRadius ** 2 - newX ** 2, 0)),
      );

    [
      furthestX,
      furthestX.scalarMultiply(-1),
      furthestY,
      furthestY.scalarMultiply(-1),
    ]
      // We need to skew the vector here as this information is used to check if
      // it is between the start and end of the circle segment
      .map((vector) =>
        this.applySkew(
          strokeUniform ? vector.multiply(this.strokeUniformScalar) : vector,
        ),
      )
      .forEach((vector) => {
        if (isBetweenVectors(vector, startCircle, endCircle)) {
          projections.push(this.applySkew(this.A).add(vector));
        }
      });

    return projections;
  }

  projectRound() {
    const projections: Point[] = [];
    /* Include the start and end points of the circle segment, so that only
      the projections contained within it are included */
    // add the orthogonal projections (start and end points of circle segment)
    projections.push(...this.projectBevel());
    // let's determines which one of the orthogonal projection is the beginning and end of the circle segment.
    // when `alpha` equals 0 or 2*PI, we have a straight line, so the way to find the start/end is different.
    const isStraightLine = this.alpha % twoMathPi === 0,
      // change the origin of the projections to point A
      // so that the cross product calculation is correct
      newOrigin = this.applySkew(this.A),
      proj0 = projections[isStraightLine ? 0 : 2].subtract(newOrigin),
      proj1 = projections[isStraightLine ? 1 : 0].subtract(newOrigin),
      // when `isStraightLine` === true, we compare with the vector opposite AB, otherwise we compare with the bisector.
      comparisonVector = isStraightLine
        ? this.applySkew(this.AB.scalarMultiply(-1))
        : this.applySkew(
            this.bisector.multiply(this.strokeUniformScalar).scalarMultiply(-1),
          ),
      // the beginning of the circle segment is always to the right of the comparison vector (cross product > 0)
      isProj0Start = crossProduct(proj0, comparisonVector) > 0,
      startCircle = isProj0Start ? proj0 : proj1,
      endCircle = isProj0Start ? proj1 : proj0;
    if (!this.isSkewed()) {
      projections.push(...this.projectRoundNoSkew(startCircle, endCircle));
    } else {
      projections.push(...this.projectRoundWithSkew(startCircle, endCircle));
    }
    return projections;
  }

  /**
   * Project stroke width on points returning projections for each point as follows:
   * - `miter`: 1 point corresponding to the outer boundary. If the miter limit is exceeded, it will be 2 points (becomes bevel)
   * - `bevel`: 2 points corresponding to the bevel possible boundaries, orthogonal to the stroke.
   * - `round`: same as `bevel` when it has no skew, with skew are 4 points.
   */
  protected projectPoints() {
    switch (this.options.strokeLineJoin) {
      case 'miter':
        return this.projectMiter();
      case 'round':
        return this.projectRound();
      default:
        return this.projectBevel();
    }
  }

  public project(): TProjection[] {
    return this.projectPoints().map((point) => ({
      originPoint: this.A,
      projectedPoint: point,
      angle: this.alpha,
      bisector: this.bisector,
    }));
  }
}
