/**
 * An implementation of the Weiler Atherton algorithm for clipping polygons.
 * This currently only handles combinations that will not result in any holes.
 * Support may be added for holes in the future.
 *
 * This algorithm is faster than the Clipper library for this task because it relies on the unique properties of the
 * circle, ellipse, or convex simple clip object.
 * It is also more precise in that it uses the actual intersection points between the circle/ellipse and polygon,
 * instead of relying on the polygon approximation of the circle/ellipse to find the intersection points.
 *
 * For more explanation of the underlying algorithm, see:
 * https://en.wikipedia.org/wiki/Weiler%E2%80%93Atherton_clipping_algorithm
 * https://www.geeksforgeeks.org/weiler-atherton-polygon-clipping-algorithm
 * https://h-educate.in/weiler-atherton-polygon-clipping-algorithm/
 */
class WeilerAthertonClipper {
  /**
   * Construct a WeilerAthertonClipper instance used to perform the calculation.
   * @param {PIXI.Polygon} polygon    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject  Object used to clip the polygon
   * @param {number} clipType         Type of clip to use
   * @param {object} clipOpts         Object passed to the clippingObject methods toPolygon and pointsBetween
   */
  constructor(polygon, clipObject, clipType, clipOpts) {
    if ( !polygon.isPositive ) {
      const msg = "WeilerAthertonClipper#constructor needs a subject polygon with a positive signed area.";
      throw new Error(msg);
    }
    clipType ??= this.constructor.CLIP_TYPES.INTERSECT;
    clipOpts ??= {};
    this.polygon = polygon;
    this.clipObject = clipObject;
    this.config = { clipType, clipOpts };
  }

  /**
   * The supported clip types.
   * Values are equivalent to those in ClipperLib.ClipType.
   * @enum {number}
   */
  static CLIP_TYPES = Object.freeze({
    INTERSECT: 0,
    UNION: 1
  });

  /**
   * The supported intersection types.
   * @enum {number}
   */
  static INTERSECTION_TYPES = Object.freeze({
    OUT_IN: -1,
    IN_OUT: 1,
    TANGENT: 0
  });

  /** @type {PIXI.Polygon} */
  polygon;

  /** @type {PIXI.Rectangle|PIXI.Circle} */
  clipObject;

  /**
   * Configuration settings
   * @type {object} [config]
   * @param {WeilerAthertonClipper.CLIP_TYPES} [config.clipType]     One of CLIP_TYPES
   * @param {object} [config.clipOpts]      Object passed to the clippingObject methods
   *                                        toPolygon and pointsBetween
   */
  config = {};

  /* -------------------------------------------- */

  /**
   * Union a polygon and clipObject using the Weiler Atherton algorithm.
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {object} clipOpts                         Options passed to the clipping object
   *                                                  methods toPolygon and pointsBetween
   * @returns {PIXI.Polygon[]}
   */
  static union(polygon, clipObject, clipOpts = {}) {
    return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.UNION, ...clipOpts});
  }

  /* -------------------------------------------- */

  /**
   * Intersect a polygon and clipObject using the Weiler Atherton algorithm.
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {object} clipOpts                         Options passed to the clipping object
   *                                                  methods toPolygon and pointsBetween
   * @returns {PIXI.Polygon[]}
   */
  static intersect(polygon, clipObject, clipOpts = {}) {
    return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.INTERSECT, ...clipOpts});
  }

  /* -------------------------------------------- */

  /**
   * Clip a given clipObject using the Weiler-Atherton algorithm.
   *
   * At the moment, this will return a single PIXI.Polygon in the array unless clipType is a union and the polygon
   * and clipObject do not overlap, in which case the [polygon, clipObject.toPolygon()] array will be returned.
   * If this algorithm is expanded in the future to handle holes, an array of polygons may be returned.
   *
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {object} [options]                        Options which configure how the union or intersection is computed
   * @param {WeilerAthertonClipper.CLIP_TYPES} [options.clipType]   One of CLIP_TYPES
   * @param {boolean} [options.canMutate]             If the WeilerAtherton constructor could mutate or not
   *                                                  the subject polygon points
   * @param {object} [options.clipOpts]               Options passed to the WeilerAthertonClipper constructor
   * @returns {PIXI.Polygon[]}                        Array of polygons and clipObjects
   */
  static combine(polygon, clipObject, {clipType, canMutate, ...clipOpts}={}) {
    if ( (clipType !== this.CLIP_TYPES.INTERSECT) && (clipType !== this.CLIP_TYPES.UNION) ) {
      throw new Error("The Weiler-Atherton clipping algorithm only supports INTERSECT or UNION clip types.");
    }
    if ( canMutate && !polygon.isPositive ) polygon.reverseOrientation();
    const wa = new this(polygon, clipObject, clipType, clipOpts);
    const trackingArray = wa.#buildPointTrackingArray();
    if ( !trackingArray.length ) return this.testForEnvelopment(polygon, clipObject, clipType, clipOpts);
    return wa.#combineNoHoles(trackingArray);
  }

  /* -------------------------------------------- */

  /**
   * Clip the polygon with the clipObject, assuming no holes will be created.
   * For a union or intersect with no holes, a single pass through the intersections will
   * build the resulting union shape.
   * @param {PolygonVertex[]} trackingArray   Array of linked points and intersections
   * @returns {[PIXI.Polygon]}
   */
  #combineNoHoles(trackingArray) {
    const clipType = this.config.clipType;
    const ln = trackingArray.length;
    let prevIx = trackingArray[ln - 1];
    let wasTracingPolygon = (prevIx.type === this.constructor.INTERSECTION_TYPES.OUT_IN) ^ clipType;
    const newPoly = new PIXI.Polygon();
    for ( let i = 0; i < ln; i += 1 ) {
      const ix = trackingArray[i];
      this.#processIntersection(ix, prevIx, wasTracingPolygon, newPoly);
      wasTracingPolygon = !wasTracingPolygon;
      prevIx = ix;
    }
    return [newPoly];
  }

  /* -------------------------------------------- */

  /**
   * Given an intersection and the previous intersection, fill the points
   * between the two intersections, in clockwise order.
   * @param {PolygonVertex} ix            Intersection to process
   * @param {PolygonVertex} prevIx        Previous intersection to process
   * @param {boolean} wasTracingPolygon   Whether we were tracing the polygon (true) or the clipObject (false).
   * @param {PIXI.Polygon} newPoly        The new polygon that results from this clipping operation
   */
  #processIntersection(ix, prevIx, wasTracingPolygon, newPoly) {
    const clipOpts = this.config.clipOpts;
    const pts = wasTracingPolygon ? ix.leadingPoints : this.clipObject.pointsBetween(prevIx, ix, clipOpts);
    for ( const pt of pts ) newPoly.addPoint(pt);
    newPoly.addPoint(ix);
  }

  /* -------------------------------------------- */

  /**
   * Test if one shape envelops the other. Assumes the shapes do not intersect.
   *  1. Polygon is contained within the clip object. Union: clip object; Intersect: polygon
   *  2. Clip object is contained with polygon. Union: polygon; Intersect: clip object
   *  3. Polygon and clip object are outside one another. Union: both; Intersect: null
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {WeilerAthertonClipper.CLIP_TYPES} clipType One of CLIP_TYPES
   * @param {object} clipOpts                         Clip options which are forwarded to toPolygon methods
   * @returns {PIXI.Polygon[]}  Returns the polygon, the clipObject.toPolygon(), both, or neither.
   */
  static testForEnvelopment(polygon, clipObject, clipType, clipOpts) {
    const points = polygon.points;
    if ( points.length < 6 ) return [];
    const union = clipType === this.CLIP_TYPES.UNION;

    // Option 1: Polygon contained within clipObject
    // We search for the first point of the polygon that is not on the boundary of the clip object.
    // One of these points can be used to determine whether the polygon is contained in the clip object.
    // If all points of the polygon are on the boundary of the clip object, which is either a circle
    // or a rectangle, then the polygon is contained within the clip object.
    let polygonInClipObject = true;
    for ( let i = 0; i < points.length; i += 2 ) {
      const point = { x: points[i], y: points[i + 1] };
      if ( !clipObject.pointIsOn(point) ) {
        polygonInClipObject = clipObject.contains(point.x, point.y);
        break;
      }
    }
    if ( polygonInClipObject ) return union ? [clipObject.toPolygon(clipOpts)] : [polygon];

    // Option 2: ClipObject contained within polygon
    const center = clipObject.center;

    // PointSourcePolygons need to have a bounds defined in order for polygon.contains to work.
    if ( polygon instanceof PointSourcePolygon ) polygon.bounds ??= polygon.getBounds();

    const clipObjectInPolygon = polygon.contains(center.x, center.y);
    if ( clipObjectInPolygon ) return union ? [polygon] : [clipObject.toPolygon(clipOpts)];

    // Option 3: Neither contains the other
    return union ? [polygon, clipObject.toPolygon(clipOpts)] : [];
  }

  /* -------------------------------------------- */

  /**
   * Construct an array of intersections between the polygon and the clipping object.
   * The intersections follow clockwise around the polygon.
   * Round all intersections and polygon vertices to the nearest pixel (integer).
   * @returns {Point[]}
   */
  #buildPointTrackingArray() {
    const labeledPoints = this.#buildIntersectionArray();
    if ( !labeledPoints.length ) return [];
    return WeilerAthertonClipper.#consolidatePoints(labeledPoints);
  }

  /* -------------------------------------------- */

  /**
   * Construct an array that holds all the points of the polygon with all the intersections with the clipObject
   * inserted, in correct position moving clockwise.
   * If an intersection and endpoint are nearly the same, prefer the intersection.
   * Intersections are labeled with isIntersection and type = out/in or in/out. Tangents are removed.
   * @returns {Point[]} Labeled array of points
   */
  #buildIntersectionArray() {
    const { polygon, clipObject } = this;
    const points = polygon.points;
    const ln = points.length;
    if ( ln < 6 ) return []; // Minimum 3 Points required

    // Need to start with a non-intersecting point on the polygon.
    let startIdx = -1;
    let a;
    for ( let i = 0; i < ln; i += 2 ) {
      a = { x: points[i], y: points[i + 1] };
      if ( !clipObject.pointIsOn(a) ) {
        startIdx = i;
        break;
      }
    }
    if ( !~startIdx ) return []; // All intersections, so all tangent

    // For each edge a|b, find the intersection point(s) with the clipObject.
    // Add intersections and endpoints to the pointsIxs array, taking care to avoid duplicating
    // points. For example, if the intersection equals a, add only the intersection, not both.
    let previousInside = clipObject.contains(a.x, a.y);
    let numPrevIx = 0;
    let lastIx = undefined;
    let secondLastIx = undefined;
    const pointsIxs = [a];
    const types = this.constructor.INTERSECTION_TYPES;
    const nIter = startIdx + ln + 2; // Add +2 to close the polygon.
    for ( let i = startIdx + 2; i < nIter; i += 2 ) {
      const j = i >= ln ? i % ln : i; // Circle back around the points as necessary.
      const b = { x: points[j], y: points[j + 1] };
      const ixs = clipObject.segmentIntersections(a, b);
      const ixsLn = ixs.length;
      let bIsIx = false;
      if ( ixsLn ) {
        bIsIx = b.x.almostEqual(ixs[ixsLn - 1].x) && b.y.almostEqual(ixs[ixsLn - 1].y);

        // If the intersection equals the current b, get that intersection next iteration.
        if ( bIsIx ) ixs.pop();

        // Determine whether the intersection is out-->in or in-->out
        numPrevIx += ixs.length;
        for ( const ix of ixs ) {
          ix.isIntersection = true;
          ix.type = lastIx ? -lastIx.type : previousInside ? types.IN_OUT : types.OUT_IN;
          secondLastIx = lastIx;
          lastIx = ix;
        }
        pointsIxs.push(...ixs);
      }

      // If b is an intersection, we will return to it next iteration.
      if ( bIsIx ) {
        a = b;
        continue;
      }

      // Each intersection represents a move across the clipObject border.
      // Count them and determine if we are now inside or outside the clipObject.
      if ( numPrevIx ) {
        const isInside = clipObject.contains(b.x, b.y);
        const changedSide = isInside ^ previousInside;
        const isOdd = numPrevIx & 1;

        // If odd number of intersections, should switch. e.g., outside --> ix --> inside
        // If even number of intersections, should stay same. e.g., outside --> ix --> ix --> outside.
        if ( isOdd ^ changedSide ) {
          if ( numPrevIx === 1 ) lastIx.isIntersection = false;
          else {
            secondLastIx.isIntersection = false;
            lastIx.type = secondLastIx.type;
          }
        }
        previousInside = isInside;
        numPrevIx = 0;
        secondLastIx = undefined;
        lastIx = undefined;
      }
      pointsIxs.push(b);
      a = b;
    }
    return pointsIxs;
  }

  /* -------------------------------------------- */

  /**
   * Given an array of labeled points, consolidate into a tracking array of intersections,
   * where each intersection contains its array of leadingPoints.
   * @param {Point[]} labeledPoints   Array of points, from _buildLabeledIntersectionsArray
   * @returns {Point[]} Array of intersections
   */
  static #consolidatePoints(labeledPoints) {

    // Locate the first intersection
    const startIxIdx = labeledPoints.findIndex(pt => pt.isIntersection);
    if ( !~startIxIdx ) return []; // No intersections, so no tracking array
    const labeledLn = labeledPoints.length;
    let leadingPoints = [];
    const trackingArray = [];

    // Closed polygon, so use the last point to circle back
    for ( let i = 0; i < labeledLn; i += 1 ) {
      const j = (i + startIxIdx) % labeledLn;
      const pt = labeledPoints[j];
      if ( pt.isIntersection ) {
        pt.leadingPoints = leadingPoints;
        leadingPoints = [];
        trackingArray.push(pt);
      } else leadingPoints.push(pt);
    }

    // Add leading points to first intersection
    trackingArray[0].leadingPoints = leadingPoints;
    return trackingArray;
  }
}
