/**
 * Calculate the inset of any non-self-overlapping polygon.
 *
 * The polygon may be
 *  - convex or non-convex
 *  - self-intersecting
 *
 * The algorithms will
 *  - first construct a general offset polygon which may be corrupt due to ouf-of-bounds or in illegal-area problems.
 *  - then dissect the possibly self-intersecting inset polygon into separate simple polygons.
 *  - then detect for each if it's out of bounds or in some illegal area.
 *  - keeping only the valid ones.
 *
 * @required sutherlandHodgman
 * @required splitPolygonToNonIntersecting
 *
 * @author   Ikaros Kappler
 * @date     2024-11-04 Ported the script to a class.
 * @modified 2024-12-02 Ported to Typescript.
 */

import { Line } from "../../Line";
import { Polygon } from "../../Polygon";
import { Vector } from "../../Vector";
import { Vertex } from "../../Vertex";
import { XYCoords } from "../../interfaces";
import { splitPolygonToNonIntersecting } from "./splitPolygonToNonIntersecting";
import { sutherlandHodgman } from "./sutherlandHodgman";

// import { GreinerHorman } from "greiner-hormann-typescript";
// import DEBUG from "debug";

export interface IPolygonInsetOptions {
  innerPolygonOffset: number;
  maxPolygonSplitDepth?: number;
  intersectionEpsilon?: number;
  removeEars?: boolean;
}

export class PolygonInset {
  /**
   * The polygon to work with.
   */
  readonly polygon: Polygon;

  /**
   * The input polygon, but without ear edges.
   */
  optimizedPolygon: Polygon;

  /**
   * The original polygon as a sequence of line objects. This is easier to use than a list of points.
   */
  originalPolygonLines: Array<Line> = [];

  /**
   * The simple inset lines, directly generated by offsetting the original polygon lines by the given amount.
   */
  insetLines: Array<Line> = [];

  /**
   * The cropped inset lines that makes up the first direct iteration of the inset polygon.
   * This one will probably be self-intersecting.
   */
  insetPolygonLines: Array<Line> = [];

  /**
   * The inset polygon lines resembling an actual polygon instance (not just a sequence of lines).
   */
  insetPolygon: Polygon;

  /**
   * Each polygon line and it's offsetted inset-line resemble a rectangular polygon.
   * Array<Polygon> with 4 vertices each
   **/
  insetRectanglePolygons: Array<Polygon>;

  /**
   * The inset polygon split up into simple non-self-intersecting polygons.
   * Represented as a list of vertex-lists. Each sub-list represents a single polygon.
   */
  splitPolygons: Array<Array<Vertex>> = [];

  /**
   * The final result: all valid split-polygons. These are making up the actual polygon inset.
   * Note: list may be empty. Depending on the offset amount there is not a guaranteed offset polygon existing.
   */
  filteredSplitPolygons: Array<Array<Vertex>>;

  /**
   * Constructs a new PolygonInset instance with the underlying given polygon to work with.
   *
   * Please note that the algorithm will reverse the vertex order if the given polygon
   * is not clockwise.
   *
   * @param {Polygon} polygon - The polygon to calculate the offset for.
   */
  constructor(polygon: Polygon) {
    this.polygon = polygon;
  }

  /**
   * This is the main method.
   *
   * Call this with the required option params to compute your desired offset polygon(s).
   *
   * @param {number} options.innerPolygonOffset - The offset to use. Should be a positive number. Correct result for negative numbers are not guaranteed.
   * @param {number?} options.maxPolygonSplitDepth - (default is the number of polygon.vertices) The maximum amount of recursive stepts to simplify the complex first iteration of the offset polygon.
   * @param {number?} options.intersectionEpsilon - (default is 1.0) The epsilon to use for detecting overlapping polygons. This indicates a tolerance value, because directly adjecent polygons may have a little arithmetic intersection area.
   * @returns {Array<Array<Vertex>>} A list of vertex-lists, each one representing a simple polygon from the offset-polygons.
   */
  public computeOutputPolygons(options: IPolygonInsetOptions): Array<Array<Vertex>> {
    // No offset implies: no change.
    if (options.innerPolygonOffset === 0) {
      return [this.polygon.vertices]; // No change
    }

    // This algorithm only works with polygon in clockwise vertex order.
    // If the polygon is not clockwise: revert.
    PolygonInset._assertPolygonIsClockwise(this.polygon.vertices);

    // The potential result polygons might overlap with other parts. These are not part of the desired
    // result set and need to be filtered out.
    // Define an intersection-epsilon: minimal critical overlaps might result of rounding errors and
    // don't represent real overlaps.
    const intersectionEpsilon: number | undefined = options.intersectionEpsilon;

    // This will create a deep clone
    this.optimizedPolygon = this.polygon.clone();

    // In case the potential result polygons are self intersecting we need to split them apart later.
    // Using the initial vertex number as the upper split limit should be safe here.
    const maxPolygonSplitDepth = options?.maxPolygonSplitDepth ?? this.optimizedPolygon.vertices.length;

    // For calculations of initial inset rectagles we need the original polygon's lines.
    this.originalPolygonLines = this.optimizedPolygon.getEdges();
    this._collectInsetLines(this.originalPolygonLines, options.innerPolygonOffset);
    this._collectInsetPolygonLines(this.insetLines);
    this.insetRectanglePolygons = this._collectRectangularPolygonInsets(this.originalPolygonLines, this.insetLines);

    // Optimize inset polygon AND the insetRectanglePolygons?
    if (options.removeEars) {
      // console.log("Remove ears");
      // This will modify both params!
      PolygonInset._optimizeInsetPolygon(this.optimizedPolygon, this.insetRectanglePolygons);
    }

    // Create a naive first inset polygon for later optimization.
    this.insetPolygon = PolygonInset.convertToBasicInsetPolygon(this.insetPolygonLines);

    // // Optimize inset polygon AND the insetRectanglePolygons!
    // if (options.removeEars) {
    //   // This will modify both params!
    //   PolygonInset._optimizeInsetPolygon(this.insetPolygon, this.insetRectanglePolygons);
    // }

    // The resulting polygon will likely intersect itself multiple times.
    // For further calculations split apart into non-self-intersecting.
    this.splitPolygons = splitPolygonToNonIntersecting(this.insetPolygon.vertices, maxPolygonSplitDepth, true); // insideBoundsOnly

    // This method was initially meant to calculate inset-polygons only.
    // But with a simple filter we COULD also create outer offset-polygons.
    // Maybe this is a task for the future
    // console.log("DEBUG", DEBUG);
    // DEBUG("TEST");
    if (options.innerPolygonOffset < 0) {
      // return [this.polygon.vertices]; // No change
    }

    // console.log("splitPolygons.length", this.splitPolygons.length);
    // Assert all polygons are clockwise!
    PolygonInset._assertAllPolygonsAreClockwise(this.splitPolygons);

    this.filteredSplitPolygons = PolygonInset._filterInnerSplitPolygonsByCoverage(
      this.splitPolygons,
      this.insetRectanglePolygons,
      intersectionEpsilon
    );
    // console.log("[0] filteredSplitPolygons.length", this.filteredSplitPolygons.length);
    this.filteredSplitPolygons = PolygonInset._filterInnerSplitPolygonsByOriginalBounds(this.filteredSplitPolygons, this.polygon);
    // console.log("[1] filteredSplitPolygons.length", this.filteredSplitPolygons.length);
    return this.filteredSplitPolygons;
  }

  /**
   * This method transforms each polygon line into a new line
   * by moving it to the inside direction of the polygon (by the given `insetAmount`).
   *
   * @param {Array<Line>} polygonLines
   * @param {number} insetAmount
   * @return {Array<Line>} The transformed lines. The result array has the same length and order as the input array.
   */
  private _collectInsetLines(polygonLines: Array<Line>, insetAmount: number): Array<Line> {
    const insetLines: Array<Line> = []; // Array<Line>
    for (var i = 0; i < polygonLines.length; i++) {
      const line = polygonLines[i];
      const perp = new Vector(line.a, line.b).perp();
      const t = insetAmount / perp.length();
      const offsetOnPerp = perp.vertAt(t);
      const diff = line.a.difference(offsetOnPerp);
      // Polygon is is clockwise order.
      // Move line inside polygon
      const movedLine = line.clone();
      movedLine.a.add(diff);
      movedLine.b.add(diff);
      insetLines.push(movedLine);
    }
    this.insetLines = insetLines;
    return insetLines;
  }

  /**
   * For a sequence of inset polygon lines get the inset polygon by detecting
   * useful intersections (by cropping or extending them).
   *
   * The returned lines resemble a new polygon.
   *
   * Please note that the returned polygon can be self-intersecting!
   *
   * @param {Array<Line>} insetLines
   * @returns {Array<Line>} The cropped or exented inset polygon lines.
   */
  private _collectInsetPolygonLines(insetLines: Array<Line>): Array<Line> {
    if (insetLines.length <= 1) {
      return [];
    }
    const insetPolygonLines: Array<Line> = [];
    // Collect first intersection at beginning :)
    const lastInsetLine: Line = insetLines[insetLines.length - 1];
    const firstInsetLine: Line = insetLines[0];
    let lastIntersectionPoint = lastInsetLine.intersection(firstInsetLine); // Must not be null
    for (var i = 0; i < insetLines.length; i++) {
      const insetLine: Line = insetLines[i];

      const nextInsetLine: Line = insetLines[(i + 1) % insetLines.length];
      // Find desired intersection
      const intersection: Vertex | null = insetLine.intersection(nextInsetLine);
      if (intersection == null) {
        console.warn("[collectInsetPolygon] WARN intersection line must not be null", i, nextInsetLine);
      } else {
        // By construction they MUST have any non-null intersection!
        if (lastIntersectionPoint != null) {
          const resultLine: Line = new Line(lastIntersectionPoint, intersection);
          insetPolygonLines.push(resultLine);
        }
      }
      lastIntersectionPoint = intersection;
    }
    // Store intermediate result for later retrieval.
    this.insetPolygonLines = insetPolygonLines;
    return insetPolygonLines;
  }

  /**
   * Converts two lists (same length) of original polygon lines and inset lines (interpreted as
   * pairs) to a list of rectangular polyons.
   *
   * @static
   * @param {Array<Line>} originalPolygonLines
   * @param {Array<Line>} insetLines
   * @returns {Array<Polygon>} A list of rectangular polygons; each returned polyon has exactly four vertices.
   */
  private _collectRectangularPolygonInsets(originalPolygonLines: Array<Line>, insetLines: Array<Line>): Array<Polygon> {
    // Convert to rectangle polygon
    const insetRectanglePolygons: Array<Polygon> = originalPolygonLines.map((polygonLine, index) => {
      const rectPolygon: Polygon = new Polygon([], false);
      // Add in original order
      rectPolygon.vertices.push(polygonLine.a.clone());
      rectPolygon.vertices.push(polygonLine.b.clone());
      // Add in reverse order
      const insetLine: Line = insetLines[index];
      rectPolygon.vertices.push(insetLine.b.clone());
      rectPolygon.vertices.push(insetLine.a.clone());
      return rectPolygon;
    });
    return insetRectanglePolygons;
  }

  /**
   * Converts a sequence of (hopefully adjacent) lines to a polygon by using all second line vertices `b`.
   *
   * @static
   * @param insetPolygonLines
   * @returns
   */
  private static convertToBasicInsetPolygon(insetPolygonLines: Array<Line>): Polygon {
    const insetPolygon = new Polygon([], false);
    insetPolygonLines.forEach((insetLine: Line): void => {
      insetPolygon.vertices.push(insetLine.a);
    });
    return insetPolygon;
  }

  /**
   * Optimize the inset polygon by removing critical `ear` edges.
   * Such an is identified by: the edge itself is completely located inside its neighbours inset rectangle.
   *
   * In this process multiple edges from the ear might be dropped and two adjacent edges get a new
   * common intersection point.
   *
   * Note: this method is not working as expected in all cases and quite experimental.
   *
   * @param insetPolygon
   * @param insetRectangles
   */
  private static _optimizeInsetPolygon = (insetPolygon: Polygon, insetRectangles: Array<Polygon>): void => {
    // Locate edge that can be removed:
    var earEdgeIndex = -1;
    var maxLimit = insetPolygon.vertices.length;
    var i = 0;
    while (
      i++ < maxLimit &&
      insetPolygon.vertices.length > 3 &&
      (earEdgeIndex = PolygonInset._locateExcessiveEarEdge(insetPolygon, insetRectangles)) != -1
    ) {
      // console.log("REMOVE EAR EDGE", earEdgeIndex);
      // As this is an ear edge: don't just remove the vertex.
      //   i) Remove the vertex and calculate the new intersection point of neighbour edges.
      //  ii) Update neightbour rectangles
      var leftEdge = insetPolygon.getEdgeAt(earEdgeIndex - 2);
      var rightEdge = insetPolygon.getEdgeAt(earEdgeIndex + 1);
      var newIntersection = leftEdge.intersection(rightEdge);
      newIntersection && rightEdge.a.set(newIntersection);

      // Update rectangles
      // ...

      insetPolygon.vertices.splice(earEdgeIndex, 1);
      insetRectangles.splice(earEdgeIndex, 1);
    }
    // TODO remove if not -1
  };

  private static _locateExcessiveEarEdge = (insetPolygon: Polygon, insetRectangles: Array<Polygon>): number => {
    for (var i = 0; i < insetPolygon.vertices.length; i++) {
      const thisEdge = insetPolygon.getEdgeAt(i);
      const leftIndex = i == 0 ? insetPolygon.vertices.length - 1 : i - 1;
      // const rightIndex = i + 1 >= insetPolygon.vertices.length ? 0 : i + 1;

      const leftRect = insetRectangles[leftIndex];
      // const rightRect = insetRectangles[rightIndex];

      const leftRectContainsThisEdge = PolygonInset._rectangleFullyContainsLine(leftRect, thisEdge);

      if (leftRectContainsThisEdge) {
        return i;
      }

      const leftEdge = insetPolygon.getEdgeAt(leftIndex);
      const thisRect = insetRectangles[i];

      const thisRectContainsLeftEdge = PolygonInset._rectangleFullyContainsLine(thisRect, leftEdge);
      if (thisRectContainsLeftEdge) {
        return i; // leftIndex;
      }
    }
    return -1;
  };

  // Pre: rectangle.vertices.length === 4
  private static _rectangleFullyContainsLine = (rectangle: Polygon, edge: Line): boolean => {
    var rectWidth: number = rectangle.getEdgeAt(0).length();
    var rectHeight: number = rectangle.getEdgeAt(1).length();

    // Both line corners must be at least 1% within the rectangle limits.
    const eps = Math.min(rectWidth, rectHeight) / 100;
    const edgeLen = edge.length();

    var epsPointA = edge.vertAt(0.01); // edgeLen * eps);
    var epsPointB = edge.vertAt(0.99); // 1.0 - edgeLen * eps);
    var centerPoint = edge.vertAt(0.5);
    return rectangle.containsVerts([epsPointA, epsPointB, centerPoint]);
  };

  /**
   * Filter split polygons: only keep those whose vertices are all contained inside the original polygon.
   * Reason: scaling too much will result in excessive translation beyond the opposite bounds of the polygon (like more than 200% of possible insetting).
   *
   * @param {Array<Array<Vertex>>} splitPolygonsVertices
   * @param {Polygon} originalPolygon
   * @return {Array<Array<Vertex>>} The filtered polygon list.
   */
  private static _filterInnerSplitPolygonsByOriginalBounds(
    splitPolygonsVertices: Array<Array<Vertex>>,
    originalPolygon: Polygon
  ) {
    return splitPolygonsVertices.filter((splitPolyVerts: Array<Vertex>, _splitPolyIndex: number): boolean => {
      return splitPolyVerts.every((splitPVert: Vertex): boolean => {
        return originalPolygon.containsVert(splitPVert);
      });
    });
  }

  /**
   * Filter split polygons: only keep those that do not (signifiantly) interset with any rectangles.
   *
   * @static
   * @param {Array<Array<Vertex>>} splitPolygonsVertices
   * @param {Array<Polygon>} insetRectanglePolygons
   * @param {number?=1.0} intersectionEpsilon - (optional, default is 1.0) A epsislon to define a tolerance for checking if two polygons intersect.
   */
  private static _filterInnerSplitPolygonsByCoverage(
    splitPolygonsVertices: Array<Array<Vertex>>,
    insetRectanglePolygons: Array<Polygon>,
    intersectionEpsilon?: number
  ) {
    // TEST: Add some jitter
    // splitPolygonsVertices.forEach(split => {
    //   split.forEach(vert => {
    //     vert.x += (0.5 - Math.random()) * 0.01;
    //     vert.y += (0.5 - Math.random()) * 0.01;
    //   });
    // });

    splitPolygonsVertices.forEach((split, index) => {
      const isCW: boolean = Polygon.utils.isClockwise(split);
      if (!isCW) {
        console.warn("------ split is not isClockwise!", index, isCW);
      }
    });
    insetRectanglePolygons.forEach((rect, index) => {
      const isCW: boolean = rect.isClockwise();
      if (!isCW) {
        console.warn("------ rect is not isClockwise!", index, isCW);
      }
    });

    const eps: number =
      intersectionEpsilon === undefined || typeof intersectionEpsilon === "undefined" ? 1.0 : intersectionEpsilon;
    return splitPolygonsVertices.filter((splitPolyVerts: Vertex[], _splitPolyIndex: number) => {
      const intersectionTestCallback = PolygonInset._hasIntersectionCallback(splitPolyVerts, eps, _splitPolyIndex);
      const intersectsWithAnyRect = insetRectanglePolygons.some(intersectionTestCallback);
      return !intersectsWithAnyRect;
    });
  }

  /**
   * This private method will reverse each polygon's vertex order that's not clockwise.
   *
   * @param {Array<Vertex[]>} polygons
   */
  private static _assertAllPolygonsAreClockwise(polygons: Array<Vertex[]>): void {
    polygons.forEach((polygonVerts: Vertex[], _polyIndex: number) => {
      // if (!Polygon.utils.isClockwise(polygonVerts)) {
      //   polygonVerts.reverse(); // Attention: this happens in-place (Array.reverse is destructive!)
      // }
      PolygonInset._assertPolygonIsClockwise(polygonVerts);
    });
  }

  /**
   * This private method will revert the vertex order if the polygon is not clockwise.
   *
   * @param {Vertex[]} polygonVerts
   */
  private static _assertPolygonIsClockwise(polygonVerts: Vertex[]): void {
    if (!Polygon.utils.isClockwise(polygonVerts)) {
      polygonVerts.reverse(); // Attention: this happens in-place (Array.reverse is destructive!)
    }
  }

  /**
   * For simplification I'm using a callback generator function here.
   *
   * @param splitPolyVerts
   * @param eps
   * @param _splitPolyIndex
   * @returns {Function} The callback for an `Array.some` parameter.
   */
  private static _hasIntersectionCallback =
    (splitPolyVerts: Vertex[], eps: number, _splitPolyIndex: number) =>
    (rectanglePoly: Polygon, _rectanglePolyIndex: number): boolean => {
      // const intersectionVerts: XYCoords[] = sutherlandHodgman(splitPolyVerts, rectanglePoly.vertices);
      const intersectionVerts: XYCoords[] = sutherlandHodgman(rectanglePoly.vertices, splitPolyVerts);

      // var intersection = GreinerHorman.intersection(sourcePolygon.vertices, clipPolygon.vertices);
      // const uniqueIntersectionVerts = clearDuplicateVertices(intersectionVerts);
      const intersectionAreaSize: number = Polygon.utils.area(intersectionVerts);
      if (intersectionAreaSize < 0) {
        console.warn(
          "%cFound a polygon split with negative area. Counterclockwise against all odds?",
          "color: red; background: black;", //  font-size: 30px",
          "intersectionAreaSize",
          intersectionAreaSize,
          "_splitPolyIndex",
          _splitPolyIndex,
          "_rectanglePolyIndex",
          _rectanglePolyIndex,
          "intersectionVerts",
          intersectionVerts
          // "uniqueIntersectionVerts",
          // uniqueIntersectionVerts
        );
      }
      // if (intersectionAreaSize >= eps) {
      //   console.log(
      //     "%cFound a polygon split for removal",
      //     "color: orange; background: grey;", //  font-size: 30px",
      //     "intersectionAreaSize",
      //     intersectionAreaSize,
      //     "_splitPolyIndex",
      //     _splitPolyIndex,
      //     "_rectanglePolyIndex",
      //     _rectanglePolyIndex,
      //     "intersectionVerts",
      //     intersectionVerts
      //     // "uniqueIntersectionVerts",
      //     // uniqueIntersectionVerts
      //   );
      // }
      // if (intersectionAreaSize < eps) {
      //   // console.log("%cHello", "color: green; background: yellow; font-size: 30px");
      //   console.log(
      //     "Polygon split can be kept. Area is position but below epsilon.",
      //     "intersectionAreaSize",
      //     intersectionAreaSize,
      //     "_splitPolyIndex",
      //     _splitPolyIndex,
      //     "_rectanglePolyIndex",
      //     _rectanglePolyIndex,
      //     "intersectionVerts",
      //     intersectionVerts
      //     // "uniqueIntersectionVerts",
      //     // uniqueIntersectionVerts
      //   );
      // }
      return intersectionAreaSize >= eps;
    };
}
