import {CLIPPER_SCALING_FACTOR} from "../../common/constants.mjs";

/**
 * @import {DeepReadonly, Point, ClipperPoint} from "@client/_types.mjs";
 */

/**
 * The node of a {@link foundry.data.PolygonTree}.
 */
export class PolygonTreeNode {

  /**
   * Create a PolygonTreeNode.
   * @param {PolygonTreeNode|null} parent    The parent node.
   * @internal
   */
  constructor(parent) {
    this.#parent = parent;
    this.#children = [];
    this.#depth = parent ? parent.depth + 1 : 0;
    this.#isHole = this.#depth % 2 === 0;
    if ( parent ) parent.#children.push(this);
    else {
      this.#polygon = null;
      this.#clipperPath = null;
    }
  }

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

  /**
   * Create a node from the Clipper path and add it to the children of the parent.
   * @param {ClipperPoint[]} clipperPath The clipper path of this node.
   * @param {PolygonTreeNode|null} parent    The parent node or `null` if root.
   * @internal
   */
  static _fromClipperPath(clipperPath, parent) {
    const node = new PolygonTreeNode(parent);
    if ( parent ) node.#clipperPath = clipperPath;
    return node;
  }

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

  /**
   * The parent of this node or `null` if this is the root node.
   * @type {PolygonTreeNode|null}
   */
  get parent() {
    return this.#parent;
  }

  #parent;

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

  /**
   * The children of this node.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<PolygonTreeNode>}
   */
  get children() {
    return this.#children;
  }

  #children;

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

  /**
   * The depth of this node.
   * The depth of the root node is 0.
   * @type {number}
   */
  get depth() {
    return this.#depth;
  }

  #depth;

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

  /**
   * Is this a hole?
   * The root node is a hole.
   * @type {boolean}
   */
  get isHole() {
    return this.#isHole;
  }

  #isHole;

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

  /**
   * Is the (sub)tree empty?
   * @type {boolean}
   */
  get isEmpty() {
    return !this.#children.length;
  }

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

  /**
   * The Clipper path of this node.
   * It is empty in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {DeepReadonly<ClipperPoint[]>|null}
   */
  get clipperPath() {
    return this.#clipperPath;
  }

  #clipperPath;

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

  /**
   * The polygon of this node.
   * It is `null` in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {PIXI.Polygon|null}
   */
  get polygon() {
    let polygon = this.#polygon;
    if ( polygon === undefined ) polygon = this.#polygon = this.#createPolygon();
    return polygon;
  }

  #polygon;

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

  /**
   * The points of the polygon ([x0, y0, x1, y1, ...]).
   * They are `null` in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<number>|null}
   */
  get points() {
    const polygon = this.polygon;
    if ( !polygon ) return null;
    return polygon.points;
  }

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

  /**
   * The path of the polygon ([{x: x0, y: y0}, {x: x1, y: y1}, ...]).
   * They are `null` in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<Point>|null}
   */
  get path() {
    let path = this.#path;
    if ( path === undefined ) path = this.#path = this.#createPath();
    return path;
  }

  #path;

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

  /**
   * The bounds of the polygon, or the combined bounds of all children in case of the root node.
   *
   * The value of this property must not be mutated.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    let bounds = this.#bounds;
    if ( bounds === undefined ) {
      if ( this.polygon ) bounds = this.polygon.getBounds();
      else {
        const nodes = this.children;
        if ( nodes.length === 0 ) bounds = new PIXI.Rectangle();
        else {
          bounds= nodes[0].bounds.clone();
          for ( let i = 1; i < nodes.length; i++ ) {
            bounds.enlarge(nodes[i].bounds);
          }
        }
      }
      this.#bounds = bounds;
    }
    return bounds;
  }

  #bounds;

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

  /**
   * The area of this node.
   * @type {number}
   */
  get area() {
    if ( this.#area !== undefined ) return this.#area;
    this.#area = this.polygon ? Math.abs(this.polygon.signedArea()) : 0;
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      this.#area -= children[i].area;
    }
    this.#area = Math.abs(this.#area);
    return this.#area;
  }

  #area;

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

  /**
   * Iterate over recursively over the children in depth-first order.
   * @returns {Generator<PolygonTreeNode>}
   * @yields {PolygonTreeNode}
   */
  *[Symbol.iterator]() {
    for ( const child of this.children ) {
      yield child;
      yield *child;
    }
  }

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

  /**
   * Find the node in this (sub)tree that contains the given point.
   * @param {Point} point              The point.
   * @returns {PolygonTreeNode|null}   The (sub)node that contains the point, if any.
   *                                   Cannot return null for {@link PolygonTree}; instead
   *                                   the root node (the tree itself) is returned.
   */
  findContainingNode(point) {
    const {x, y} = point;
    if ( this.parent && (!this.bounds.contains(x, y) || !this.polygon.contains(x, y)) ) return null;
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const result = children[i].findContainingNode(point);
      if ( result ) return result;
    }
    return this;
  }

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

  /**
   * Test whether given point is contained within this (sub)tree.
   * If `distance` is is nonzero, true is returned if and only if the signed distance from the point to the boundary of
   * the (sub)tree is less than or equal to `distance`. The signed distance is positive for points in the exterior of
   * the (sub)tree and negative for points within the interior of the (sub)tree.
   * @param {Point} point           The point.
   * @param {number} [distance=0]   The tolerance of the containment test.
   * @returns {boolean}
   * @example Test whether the point (x, y) is contained within the polygon tree
   * ```js
   * polygonTree.testPoint({x, y});
   * ```
   * @example Test whether the circle at (x, y) with radius r (positive) intersects the polygon tree
   * ```js
   * polygonTree.testPoint({x, y}, r);
   * ```
   * @example Test whether the circle at (x, y) with radius r (positive) is contained within the polygon tree
   * ```js
   * polygonTree.testPoint({x, y}, -r);
   * ```
   */
  testPoint(point, distance=0) {
    const negative = distance < 0;
    let node = this.findContainingNode(point);
    let outside;

    // If containing node is not a hole, the point is contained
    if ( node ) {
      if ( node.isHole === negative ) return !negative;
      outside = false;
    } else {
      if ( negative ) return false;
      node = this;
      outside = true;
    }

    // Without tolerance circle intersection test are not necessary
    if ( distance === 0 ) return false;
    distance = Math.abs(distance);

    // If there's a containing (or root) node not is not a hole, ...
    if ( node.parent && !(outside && node.isHole) ) {

      // ... test whether the circle intersects any edge of the polygon of this node
      const intersects = foundry.utils.pathCircleIntersects(node.points, true, point, distance);
      if ( intersects ) return !negative;
      if ( outside ) return false;
    }

    // Test whether any of the children's paths of the containing node intersect
    const {x, y} = point;
    const children = node.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const child = children[i];

      // Test whether the circle intersects the bounds of this child node
      const {left, right, top, bottom} = child.bounds;
      if ( (x < left - distance) || (x > right + distance) || (y < top - distance) || (y > bottom + distance) ) {
        continue;
      }

      // Test whether the circle intersects any edge of the polygon of this child node
      const intersects = foundry.utils.pathCircleIntersects(child.points, true, point, distance);
      if ( intersects ) return !negative;
    }
    return negative;
  }

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

  /**
   * Test circle containment/intersection with this (sub)tree.
   * @param {Point} center     The center point of the circle.
   * @param {number} radius    The radius of the circle.
   * @returns {-1|0|1}          - -1: the circle is in the exterior and does not intersect the boundary.
   *                            - 0: the circle is intersects the boundary.
   *                            - 1: the circle is in the interior and does not intersect the boundary.
   */
  testCircle(center, radius) {
    let node = this.findContainingNode(center);
    let outside;

    // If containing node is not a hole, the point is contained
    if ( node ) outside = false;
    else {
      node = this;
      outside = true;
    }

    // If there's a containing (or root) node not is not a hole, ...
    if ( node.parent && !(outside && node.isHole) ) {

      // ... test whether the circle intersects any edge of the polygon of this node
      const intersects = foundry.utils.pathCircleIntersects(node.points, true, center, radius);
      if ( intersects ) return 0;
      if ( outside ) return -1;
    }

    // Test whether any of the children's paths of the containing node intersect
    const {x, y} = center;
    const children = node.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const child = children[i];

      // Test whether the circle intersects the bounds of this child node
      const {left, right, top, bottom} = child.bounds;
      if ( (x < left - radius) || (x > right + radius) || (y < top - radius) || (y > bottom + radius) ) continue;

      // Test whether the circle intersects any edge of the polygon of this child node
      const intersects = foundry.utils.pathCircleIntersects(child.points, true, center, radius);
      if ( intersects ) return 0;
    }
    return node.isHole ? -1 : 1;
  }

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

  /**
   * Find a point inside this polygon tree that is closest to the given reference point.
   * @param {Point} point    The reference point.
   * @returns {Point}        The closest point to the reference point in the polygon tree.
   */
  findClosestPoint(point) {
    let node = this.findContainingNode(point);
    let outside;
    if ( node ) {
      if ( !node.isHole ) return {x: point.x, y: point.y};
      outside = false;
    } else {
      node = this;
      outside = true;
    }
    let distance = Infinity;
    let closest;
    if ( node.parent && !(outside && node.isHole) ) {
      closest = foundry.utils.closestPointToPath(point, node.points, true);
      const dx = closest.x - point.x;
      const dy = closest.y - point.y;
      distance = (dx * dx) + (dy * dy);
      if ( outside ) return closest;
    }
    const children = node.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const result = foundry.utils.closestPointToPath(point, children[i].points, true);
      const dx = result.x - point.x;
      const dy = result.y - point.y;
      const d = (dx * dx) + (dy * dy);
      if ( d < distance ) {
        distance = d;
        closest = result;
      }
    }
    return closest;
  }

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

  /**
   * Create the polygon of this node.
   * @returns {PIXI.Polygon|null}
   */
  #createPolygon() {
    if ( !this.parent ) return null;
    const polygon = PIXI.Polygon.fromClipperPoints(this.clipperPath, {scalingFactor: CLIPPER_SCALING_FACTOR});
    polygon._isPositive = !this.isHole;
    return polygon;
  }

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

  /**
   * Create the path of this node.
   * @returns {Point[]|null}
   */
  #createPath() {
    if ( !this.parent ) return null;
    const path = [];
    const points = this.points;
    for ( let i = 0; i < points.length; i += 2 ) {
      const x = points[i];
      const y = points[i + 1];
      path.push({x, y});
    }
    return path;
  }
}

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

/**
 * A polygon tree.
 */
export class PolygonTree extends PolygonTreeNode {

  /**
   * Create a PolygonTree.
   */
  constructor() {
    super(null);
  }

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

  /**
   * Create the tree from a Clipper polygon tree.
   * @param {ClipperLib.PolyTree} clipperPolyTree
   * @returns {PolygonTree}
   */
  static fromClipperPolyTree(clipperPolyTree) {
    const visit = (clipperPolyNode, parent) => {
      const clipperPath = clipperPolyNode.Contour();
      const node = PolygonTreeNode._fromClipperPath(clipperPath, parent);
      clipperPolyNode.Childs().forEach(child => visit(child, node));
      return node;
    };
    const tree = new PolygonTree();
    clipperPolyTree.Childs().forEach(child => visit(child, tree));
    return tree;
  }

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

  /**
   * The polygons of this polygon tree.
   *
   * The value of this property must not be mutated.
   * @type {ReadonlyArray<PIXI.Polygon>}
   */
  get polygons() {
    return this.#polygons ??= Object.freeze(Array.from(this, node => node.polygon));
  }

  #polygons;

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

  /**
   * The Clipper paths of this polygon tree.
   *
   * The value of this property must not be mutated.
   * @type {DeepReadonly<ClipperPoint[][]>}
   */
  get clipperPaths() {
    return this.#clipperPaths ??= Object.freeze(Array.from(this, node => node.clipperPath));
  }

  #clipperPaths;

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

  /**
   * The triangulation of this polygon tree.
   *
   * The value of this property must not be mutated.
   * @type {Readonly<{vertices: Float32Array; indices: Uint16Array|Uint32Array}>}
   */
  get triangulation() {
    if ( this.#triangulation ) return this.#triangulation;
    let vertexIndex = 0;
    let vertexDataSize = 0;
    for ( const node of this ) vertexDataSize += node.points.length;
    const vertexData = new Float32Array(vertexDataSize);
    const indices = [];
    for ( const node of this ) {
      if ( node.isHole ) continue;
      const holes = [];
      let points = node.points;
      for ( const hole of node.children ) {
        holes.push(points.length / 2);
        points = points.concat(hole.points);
      }
      const triangles = PIXI.utils.earcut(points, holes, 2);
      const offset = vertexIndex / 2;
      for ( let i = 0; i < triangles.length; i++ ) indices.push(triangles[i] + offset);
      for ( let i = 0; i < points.length; i++ ) vertexData[vertexIndex++] = points[i];
    }
    const indexDataType = vertexDataSize / 2 > 65536 ? Uint32Array : Uint16Array;
    const indexData = new indexDataType(indices);
    return this.#triangulation = {vertices: vertexData, indices: indexData};
  }

  #triangulation;

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

  /**
   * Draw the polygon tree into the Graphics element.
   * @param {PIXI.Graphics} graphics   The Graphics element.
   */
  drawShape(graphics) {
    for ( const node of this ) {
      if ( node.isHole ) continue;
      graphics.drawShape(node.polygon);
      graphics.beginHole();
      for ( const hole of node.children ) graphics.drawShape(hole.polygon);
      graphics.endHole();
    }
  }

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

  /**
   * Compute the intersection of the polygon tree with the given polygon.
   * @param {PIXI.Polygon} polygon       The polygon to intersect the polygon tree with.
   * @param {object} [options]           Additional options.
   * @param {number} [options.clipType]  The Clipper clip type. Default: `ClipperLib.ClipType.ctIntersection`.
   * @param {number} [options.fillType]  The Clipper fill type used for the polygon.
   *                                     Default: `ClipperLib.PolyFillType.pftEvenOdd`.
   * @returns {PolygonTree}              The result of the intersection.
   */
  intersectPolygon(polygon, options) {
    return this.intersectClipper(polygon.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR}), options);
  }

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

  /**
   * Compute the intersection of the polygon tree with the given Clipper path.
   * @param {ClipperPoint[]} path        The Clipper path to intersect the polygon tree with
   *                                     (the scaling factor must be {@link CONST.CLIPPER_SCALING_FACTOR}).
   * @param {object} [options]           Additional options.
   * @param {number} [options.clipType]  The Clipper clip type. Default: `ClipperLib.ClipType.ctIntersection`.
   * @param {number} [options.fillType]  The Clipper fill type used for the Clipper path.
   *                                     Default: `ClipperLib.PolyFillType.pftEvenOdd`.
   * @returns {PolygonTree}              The result of the intersection.
   */
  intersectClipper(path, {clipType=ClipperLib.ClipType.ctIntersection,
    fillType=ClipperLib.PolyFillType.pftEvenOdd}={}) {
    const clipper = new ClipperLib.Clipper();
    const solution = new ClipperLib.PolyTree();
    clipper.AddPaths(this.clipperPaths, ClipperLib.PolyType.ptSubject, true);
    clipper.AddPath(path, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(clipType, solution, ClipperLib.PolyFillType.pftEvenOdd, fillType);
    return PolygonTree.fromClipperPolyTree(solution);
  }

  /* -------------------------------------------- */
  /*  Sampling API                                */
  /* -------------------------------------------- */

  /**
   * The compiled data used to sample points inside the polygon tree.
   * @type {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   vertices: Float32Array;
   *   indices: Uint16Array|Uint32Array;
   * }}
   */
  #interiorSamplingData;

  /**
   * The compiled data used to sample points on the polygon tree boundary.
   * @type {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   x: Float64Array;
   *   y: Float64Array;
   *   dx: Float64Array;
   *   dy: Float64Array;
   * }}
   */
  #boundarySamplingData;

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

  /**
   * Sample a point from the polygon tree interior.
   * @param {Point} [out]  A point to write to.
   * @returns {Point}      The sampled point.
   * @throws {Error}       If the polygon tree interior is empty.
   */
  sampleInterior(out) {
    const data = this.#interiorSamplingData ??= this.#createInteriorSamplingData();
    const {weights, totalWeight, vertices, indices} = data;
    if ( totalWeight <= 0 ) throw new Error("Cannot sample an empty PolygonTree.");

    const point = out ?? {x: 0, y: 0};
    const k = PolygonTree.#selectSamplingIndex(weights, totalWeight) * 3;
    const i0 = indices[k] * 2;
    const i1 = indices[k + 1] * 2;
    const i2 = indices[k + 2] * 2;
    const x0 = vertices[i0];
    const y0 = vertices[i0 + 1];
    const x1 = vertices[i1];
    const y1 = vertices[i1 + 1];
    const x2 = vertices[i2];
    const y2 = vertices[i2 + 1];
    const r1 = Math.sqrt(Math.random());
    const r2 = Math.random();
    const s = r1 * (1 - r2);
    const t = r1 * r2;
    point.x = x0 + ((x1 - x0) * s) + ((x2 - x0) * t);
    point.y = y0 + ((y1 - y0) * s) + ((y2 - y0) * t);
    return point;
  }

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

  /**
   * Sample a point from the polygon tree boundary.
   * @param {Point} [out]  A point to write to.
   * @returns {Point}      The sampled point.
   * @throws {Error}       If the polygon tree boundary is empty.
   */
  sampleBoundary(out) {
    const data = this.#boundarySamplingData ??= this.#createBoundarySamplingData();
    const {weights, totalWeight, x, y, dx, dy} = data;
    if ( totalWeight <= 0 ) throw new Error("Cannot sample an empty PolygonTree.");

    const point = out ?? {x: 0, y: 0};
    const index = PolygonTree.#selectSamplingIndex(weights, totalWeight);
    const t = Math.random();
    point.x = x[index] + (dx[index] * t);
    point.y = y[index] + (dy[index] * t);
    return point;
  }

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

  /**
   * Create the compiled interior sampling data.
   * @returns {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   vertices: Float32Array;
   *   indices: Uint16Array|Uint32Array;
   * }}
   */
  #createInteriorSamplingData() {
    const {vertices, indices} = this.triangulation;
    const triangleCount = Math.floor(indices.length / 3);
    const weights = new Float64Array(triangleCount);
    let totalWeight = 0;
    for ( let i = 0, k = 0; i < triangleCount; i++, k += 3 ) {
      const i0 = indices[k] * 2;
      const i1 = indices[k + 1] * 2;
      const i2 = indices[k + 2] * 2;
      const x0 = vertices[i0];
      const y0 = vertices[i0 + 1];
      const x1 = vertices[i1];
      const y1 = vertices[i1 + 1];
      const x2 = vertices[i2];
      const y2 = vertices[i2 + 1];
      totalWeight += Math.abs(((x1 - x0) * (y2 - y0)) - ((x2 - x0) * (y1 - y0))) / 2;
      weights[i] = totalWeight;
    }
    return {weights, totalWeight, vertices, indices};
  }

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

  /**
   * Create the compiled boundary sampling data.
   * @returns {{
   *   weights: Float64Array;
   *   totalWeight: number;
   *   x: Float64Array;
   *   y: Float64Array;
   *   dx: Float64Array;
   *   dy: Float64Array;
   * }}
   */
  #createBoundarySamplingData() {
    let segmentCount = 0;
    for ( const node of this ) segmentCount += (node.points.length / 2);

    const weights = new Float64Array(segmentCount);
    const x = new Float64Array(segmentCount);
    const y = new Float64Array(segmentCount);
    const dx = new Float64Array(segmentCount);
    const dy = new Float64Array(segmentCount);
    let totalWeight = 0;
    let j = 0;
    for ( const node of this ) {
      const points = node.points;
      let x0 = points[points.length - 2];
      let y0 = points[points.length - 1];
      for ( let i = 0; i < points.length; i += 2 ) {
        const x1 = points[i];
        const y1 = points[i + 1];
        const ddx = x1 - x0;
        const ddy = y1 - y0;
        x[j] = x0;
        y[j] = y0;
        dx[j] = ddx;
        dy[j] = ddy;
        totalWeight += Math.hypot(ddx, ddy);
        weights[j++] = totalWeight;
        x0 = x1;
        y0 = y1;
      }
    }
    return {weights, totalWeight, x, y, dx, dy};
  }

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

  /**
   * Pick a sampling element from a cumulative weight table.
   * @param {Float64Array} weights  The cumulative weights.
   * @param {number} totalWeight    The total cumulative weight.
   * @returns {number}              The selected element index.
   */
  static #selectSamplingIndex(weights, totalWeight) {
    const target = Math.random() * totalWeight;
    let lo = 0;
    let hi = weights.length - 1;
    while ( lo < hi ) {
      const mid = (lo + hi) >>> 1;
      if ( target < weights[mid] ) hi = mid;
      else lo = mid + 1;
    }
    return lo;
  }
}
