import {PolygonTree} from "@client/data/polygon-tree.mjs";
import {CLIPPER_SCALING_FACTOR} from "@common/constants.mjs";
import {orient2dFast} from "@client/utils/_module.mjs";

/**
 * @import {ClipperPoint} from "@client/_types.mjs";
 * @import {ElevatedSurfaceExposureOptions} from "../_types.mjs";
 * @import PointSourcePolygon from "./source-polygon.mjs";
 */

/**
 * This class computes the elevated surface exposure polygon tree.
 */
export default class ElevatedSurfaceExposureGenerator {
  /**
   * @param {PointSourcePolygon} polygon                  The source polygon the exposure is computed for
   * @param {ElevatedSurfaceExposureOptions} [options]    The surface exposure options
   */
  constructor(polygon, options={}) {
    this.#polygon = polygon;
    this.#threshold = options.threshold ?? 0;
  }

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

  /**
   * Compute the elevated surface exposure for the given source polygon using the
   * {@link ElevatedSurfaceExposureGenerator}.
   * @param {PointSourcePolygon} polygon                  The source polygon the exposure is computed for
   * @param {ElevatedSurfaceExposureOptions} [options]    The surface exposure options
   * @returns {PolygonTree|null}                          The computed elevated surface exposure or null if empty
   */
  static compute(polygon, options) {
    return new this(polygon, options).compute();
  }

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

  /**
   * The Clipper instance.
   * @type {ClipperLib.Clipper}
   */
  #clipper = new ClipperLib.Clipper();

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

  /**
   * The source polygon the exposure is computed for.
   * @type {PointSourcePolygon}
   */
  get polygon() {
    return this.#polygon;
  }

  #polygon;

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

  /**
   * The clipper path of the polygon offset by 0.5 pixels.
   * @type {ClipperPoint[]|undefined}
   */
  #polygonPath;

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

  /**
   * The polygon bounds padded by 0.5 pixels.
   * @type {PIXI.Bounds|undefined}
   */
  #polygonBounds;

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

  /**
   * Points with at most this distance (grid units) from the surface are exposed.
   * @type {number}
   */
  get threshold() {
    return this.#threshold;
  }

  #threshold;

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

  /**
   * The result of the computation, which is null if the surface exposure is empty.
   * @type {PolygonTree|null}
   * @throws {Error} If {@link ElevatedSurfaceExposureGenerator#compute} wasn't called yet.
   */
  get result() {
    if ( this.#result === undefined ) throw new Error("Not computed yet");
    return this.#result;
  }

  #result;

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

  /**
   * Compute the surface exposure.
   * @returns {PolygonTree|null}    The computed elevated surface exposure or null if empty
   */
  compute() {
    if ( !this.#polygon.points.length || !(this.#threshold > 0) ) return this.#result = null;

    // Compute all visible surface areas
    const elevationThreshold = this.#polygon.level.elevation.base;
    const type = this.#polygon.config.type;
    const visibleAreas = [];
    const relevantSurfaces = this.#polygon.scene.getSurfaces({type, level: this.#polygon.level.id, exposure: true});
    const occludedSurfaces = this.#polygon.scene.isView ? canvas.masks.occlusion.occludedSurfaces : null;
    for ( let i = relevantSurfaces.length - 1; i >= 0; i-- ) {
      const surface = relevantSurfaces[i];

      // We only consider surfaces that are above the elevation of this polygon's origin
      if ( surface.elevation <= elevationThreshold ) break;

      // If the surface is at infinity elevation, it cannot be exposed
      if ( surface.elevation === Infinity ) continue;

      // If we didn't skip occluded surfaces, tokens could see where they shouldn't if they are underneath the surface
      // because of an light source outside of the building
      const occluded = occludedSurfaces?.has(surface);
      if ( occluded ) continue;

      // We unionize the exposure of all surfaces ignoring that a surface could be shadowed by another surface.
      // This may lead to incorrect surface exposure, but usually doesn't because surfaces are expected to have walls,
      // which already block line of sight to a surface that would be shadowed by the surface the walls are under.
      const visibleArea = this.#computeVisibleFacingSurfaceArea(surface.region.polygonTree, surface.elevation);
      if ( visibleArea.length ) visibleAreas.push(visibleArea);
    }

    // We don't need those anymore at this point. Clear for next compute call because the polygon could change.
    this.#polygonPath = undefined;
    this.#polygonBounds = undefined;

    return this.#computeSurfaceExpose(visibleAreas);
  }

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

  /**
   * Compute the union of the visibility shapes of all applicable surfaces.
   * If this polygon is constrained, constrain the surface exposure by the same shapes so that no more is exposed
   * than what would be visible without any surfaces or walls.
   * @param {ClipperPoint[][]} visibleAreas   The visible facing surface areas
   * @returns {PolygonTree|null}              The computed elevated surface exposure or null if empty
   */
  #computeSurfaceExpose(visibleAreas) {
    if ( !visibleAreas.length ) return this.#result = null;
    for ( const visibleArea of visibleAreas ) this.#clipper.AddPaths(visibleArea, ClipperLib.PolyType.ptSubject, true);
    const constrainedVisibleArea = new ClipperLib.PolyTree();
    const {boundaryShapes} = this.#polygon.config;
    if ( boundaryShapes.length ) {
      for ( let [i, boundaryShape] of boundaryShapes.entries() ) {
        if ( i > 0 ) {
          const intermediate = [];
          this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, intermediate, ClipperLib.PolyFillType.pftPositive,
            ClipperLib.PolyFillType.pftEvenOdd);
          this.#clipper.Clear();
          this.#clipper.AddPaths(intermediate, ClipperLib.PolyType.ptSubject, true);
        }
        if ( !(boundaryShape instanceof PIXI.Polygon) ) {
          boundaryShape = boundaryShape.toPolygon({density: this.#polygon.config.density});
        }
        const boundaryShapePath = boundaryShape.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR});
        this.#clipper.AddPath(boundaryShapePath, ClipperLib.PolyType.ptClip, true);
      }
      this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, constrainedVisibleArea,
        ClipperLib.PolyFillType.pftPositive, ClipperLib.PolyFillType.pftEvenOdd);
    } else {
      this.#clipper.Execute(ClipperLib.ClipType.ctUnion, constrainedVisibleArea, ClipperLib.PolyFillType.pftPositive,
        ClipperLib.PolyFillType.pftEvenOdd);
    }
    this.#clipper.Clear();
    if ( !constrainedVisibleArea.ChildCount() ) return this.#result = null;
    return this.#result = PolygonTree.fromClipperPolyTree(constrainedVisibleArea);
  }

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

  /**
   * Compute the area of the surface that is visible.
   *
   * We convert each visible boundary path to a shape that covers the area of the surface where the distance
   * between a ray going through the boundary path from the origin has a distance equal to or less than `expose`
   * in z-direction
   * ```
   *      / ray through boundary
   *     /| z-distance to surface
   *    /_|_______ surface
   *   / ^ visible boundary shape
   *  /
   * origin
   * ```
   * @param {PolygonTree} polygonTree  The polygon tree of the surface
   * @param {number} elevation         The elevation of the surface
   * @returns {ClipperPoint[][]}       The visible surface boundary shapes
   */
  #computeVisibleFacingSurfaceArea(polygonTree, elevation) {
    const visibleBoundary = this.#computeVisibleSurfaceBoundary(polygonTree);
    if ( !visibleBoundary.length ) return [];
    const origin = this.#polygon.origin;
    const X = Math.round(origin.x * CLIPPER_SCALING_FACTOR);
    const Y = Math.round(origin.y * CLIPPER_SCALING_FACTOR);
    const d = this.#polygon.level.parent.dimensions;
    const r = ClipperLib.ClipperBase.hiRange;
    const factor = Math.min(1 + (this.#threshold / Math.max(elevation - origin.elevation, 0)),
      (r / (Math.max(d.width, d.height) * CLIPPER_SCALING_FACTOR)) - 1);
    for ( const path of visibleBoundary ) {
      for ( let i = path.length - 1; i >= 0; i-- ) {
        const p = path[i];
        path.push({
          X: Math.clamp(Math.round(X + ((p.X - X) * factor)), -r, r),
          Y: Math.clamp(Math.round(Y + ((p.Y - Y) * factor)), -r, r)
        });
      }
      if ( !ClipperLib.Clipper.Orientation(path) ) path.reverse();
      this.#clipper.AddPath(path, ClipperLib.PolyType.ptSubject, true);
    }
    this.#clipper.AddPaths(polygonTree.clipperPaths, ClipperLib.PolyType.ptClip, true);
    const visibleArea = [];
    this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, visibleArea, ClipperLib.PolyFillType.pftPositive,
      ClipperLib.PolyFillType.pftEvenOdd);
    this.#clipper.Clear();
    if ( this.#polygon.scene.isView ) {
      let hasOccludedArea = false;
      for ( const surface of canvas.masks.occlusion.occludedSurfaces ) {
        if ( surface.elevation > elevation ) continue;
        if ( !surface.region.bounds.intersects(polygonTree.bounds) ) continue;
        if ( surface.region.polygonTree === polygonTree ) continue;
        this.#clipper.AddPaths(surface.region.clipperPaths, ClipperLib.PolyType.ptClip, true);
        hasOccludedArea = true;
      }
      if ( hasOccludedArea ) {
        this.#clipper.AddPaths(visibleArea, ClipperLib.PolyType.ptSubject, true);
        this.#clipper.Execute(ClipperLib.ClipType.ctDifference, visibleArea, ClipperLib.PolyFillType.pftPositive,
          ClipperLib.PolyFillType.pftPositive);
        this.#clipper.Clear();
      }
    }
    return visibleArea;
  }

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

  /**
   * Compute the boundary of the surface that is visible.
   *
   * We intersect the boundary paths that are facing the origin with the LOS polygon to compute
   * the parts of the boundary paths that are visible from the origin
   * @param {PolygonTree} polygonTree  The polygon tree of the surface
   * @returns {ClipperPoint[][]}       The visible surface boundary shapes
   */
  #computeVisibleSurfaceBoundary(polygonTree) {
    const facingBoundary = this.#computeFacingSurfaceBoundary(polygonTree);
    if ( !facingBoundary.length ) return [];
    this.#clipper.AddPaths(facingBoundary, ClipperLib.PolyType.ptSubject, false);
    this.#clipper.AddPath(this.#getPolygonPath(), ClipperLib.PolyType.ptClip, true);
    const visibleBoundary = new ClipperLib.PolyTree();
    this.#clipper.Execute(ClipperLib.ClipType.ctIntersection, visibleBoundary, ClipperLib.PolyFillType.pftPositive,
      ClipperLib.PolyFillType.pftEvenOdd);
    this.#clipper.Clear();
    return ClipperLib.Clipper.OpenPathsFromPolyTree(visibleBoundary);
  }

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

  /**
   * Compute the boundary of the surface that is facing the origin.
   * @param {PolygonTree} polygonTree  The polygon tree of the surface
   * @returns {ClipperPoint[][]}       The visible surface boundary shapes
   */
  #computeFacingSurfaceBoundary(polygonTree) {
    const {origin} = this.#polygon;
    const bounds = this.#getPolygonBounds();
    const paths = [];

    // Iterate all nodes of the polygon tree to find the boundary path that face the origin
    for ( const node of polygonTree ) {
      if ( !node.bounds.intersects(bounds) ) continue;
      const n = node.path.length;
      let i0 = n - 1;
      let i1 = 0;

      // Find the first edge that is not facing the origin
      while ( i1 < n ) {
        const p0 = node.path[i0];
        const p1 = node.path[i1];
        const facing = orient2dFast(p0, p1, origin) > 0;
        if ( !facing ) break;
        i0 = i1;
        i1++;
      }

      // If all edges are facing the origin, return the full closed path
      if ( i1 === n ) {
        paths.push([...node.clipperPath, node.clipperPath[0]]);
        continue;
      }

      // Otherwise find all subpaths that are facing the origin
      i0 = i1;
      i1 = (i1 + 1) % n;
      const i2 = i0;
      let current = [];
      while ( i1 !== i2 ) {
        const p0 = node.path[i0];
        const p1 = node.path[i1];
        const facing = orient2dFast(p0, p1, origin) > 0;
        if ( facing ) {
          if ( !current.length ) current.push(node.clipperPath[i0]);
          current.push(node.clipperPath[i1]);
        } else if ( current.length ) {
          paths.push(current);
          current = [];
        }
        i0 = i1;
        i1 = (i1 + 1) % n;
      }
      if ( current.length ) paths.push(current);
    }
    return paths;
  }

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

  /**
   * Get the clipper path of the polygon offset by 0.5 pixels.
   * @returns {ClipperPoint[]}
   */
  #getPolygonPath() {

    // Create the clipper path of the polygon offset by 0.5 pixels, because we need it to be
    // a bit bigger to make sure almost collinear surface boundaries are not discarded
    if ( !this.#polygonPath ) {
      const polygonPath = this.#polygon.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR});
      const offsetter = new ClipperLib.ClipperOffset();
      offsetter.AddPath(polygonPath, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
      const solution = [];
      offsetter.Execute(solution, CLIPPER_SCALING_FACTOR / 2);
      this.#polygonPath = solution.at(0) ?? [];
    }

    return this.#polygonPath;
  }

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

  /**
   * Get the bounds of the polygon padded by 0.5 pixels.
   * @returns {PIXI.Rectangle}
   */
  #getPolygonBounds() {
    return this.#polygonBounds ??= this.#polygon.bounds.clone().pad(0.5);
  }
}
