import BaseRegion from "@common/documents/region.mjs";
import CanvasDocumentMixin from "./abstract/canvas-document.mjs";
import {CLIPPER_SCALING_FACTOR, REGION_EVENTS, REGION_MOVEMENT_SEGMENTS, TOKEN_SHAPES} from "@common/constants.mjs";
import {PolygonTree} from "../data/polygon-tree.mjs";
import {fromUuid} from "../utils/helpers.mjs";
import {BaseShapeData} from "@common/data/_module.mjs";
import TokenDocument from "./token.mjs";
import {DataFieldOperator} from "@common/data/operators.mjs";

/**
 * @import {Point, ElevatedPoint, ClipperPoint, DeepReadonly, TokenPanningOptions} from "@client/_types.mjs";
 * @import {RegionData, RegionSocketEvent, TokenCoordinates, TokenData} from "@common/documents/_types.mjs";
 * @import {RegionEvent, RegionMovementSegment, RegionSegmentizeMovementPathWaypoint} from "./_types.mjs";
 * @import Scene from "./scene.mjs";
 * @import {PointSourcePolygon} from "@client/canvas/geometry/_module.mjs";
 * @import {PointSourcePolygonConfig} from "@client/canvas/geometry/_types.mjs";
 * @import {DatabaseCreateOperation} from "@common/abstract/_types.mjs";
 */

/**
 * The client-side Region document which extends the common BaseRegion model.
 * @extends BaseRegion
 * @mixes CanvasDocumentMixin
 * @category Documents
 *
 * @see {@link foundry.documents.Scene}: The Scene document type which contains Region documents
 * @see {@link foundry.applications.sheets.RegionConfig}: The Region configuration application
 */
export default class RegionDocument extends CanvasDocumentMixin(BaseRegion) {

  /**
   * Shared point instance.
   * @type {Point}
   */
  static #SHARED_POINT = {x: 0.0, y: 0.0};

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Does this Region have a single shape that is not a hole?
   * @type {boolean}
   */
  get isSingleShape() {
    return (this.shapes.length === 1) && !this.shapes[0].hole;
  }

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

  /**
   * The polygons of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.polygons`.
   * @type {ReadonlyArray<PIXI.Polygon>}
   */
  get polygons() {
    return this.polygonTree.polygons;
  }

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

  /**
   * The polygon tree of this Region.
   *
   * The value of this property must not be mutated.
   * @type {PolygonTree}
   */
  get polygonTree() {
    return this.#polygonTree ??= PolygonTree.fromClipperPolyTree(this.clipperPolyTree);
  }

  #polygonTree;

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

  /**
   * The Clipper paths of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.clipperPaths`.
   * @type {DeepReadonly<ClipperPoint[][]>}
   */
  get clipperPaths() {
    return this.polygonTree.clipperPaths;
  }

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

  /**
   * The Clipper polygon tree of this Region.
   *
   * The value of this property must not be mutated.
   * @type {ClipperLib.PolyTree}
   */
  get clipperPolyTree() {
    return this.#clipperPolyTree ??= this._createClipperPolyTree(this.shapes, this._shapeConstraints);
  }

  #clipperPolyTree;

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

  /**
   * The triangulation of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.triangulation`.
   * @type {Readonly<{vertices: Float32Array; indices: Uint16Array|Uint32Array}>}
   */
  get triangulation() {
    return this.polygonTree.triangulation;
  }

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

  /**
   * The bounds of this Region.
   *
   * The value of this property must not be mutated.
   *
   * Alias for `this.polygonTree.bounds`.
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return this.polygonTree.bounds;
  }

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

  /**
   * The area of this Region.
   *
   * Alias for `this.polygonTree.area`.
   * @type {number}
   */
  get area() {
    return this.polygonTree.area;
  }

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

  /**
   * The tokens inside this region.
   * @type {ReadonlySet<TokenDocument>}
   * @readonly
   */
  tokens = new Set();

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

  /**
   * This makes sure that the second preparation during world initialization knows to add the region
   * to the attachments of the attached token.
   * It abuses the subclass property instantiation issue.
   * @ignore
   */
  attachment = game._documentsReady ? this.attachment : {...this.attachment, token: undefined};

  /* -------------------------------------------- */
  /*  Document Preparation                        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.elevation.bottom ??= -Infinity;
    this.elevation.top ??= Infinity;
    const addToAttachments = this.attachment.token === undefined;
    this.attachment.token = this.parent?.tokens.get(this._source.attachment.token) ?? null;
    if ( addToAttachments ) this.attachment.token?.attachments.regions.add(this);
  }

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

  /** @inheritDoc */
  clone(data, context) {
    const clone = super.clone(data, context);
    if ( (clone.parent === this.parent) && foundry.utils.equals(clone._source.shapes, this._source.shapes)
      && foundry.utils.equals(clone._source._shapeConstraints, this._source._shapeConstraints)) {
      clone.#polygonTree = this.#polygonTree;
      clone.#clipperPolyTree = this.#clipperPolyTree;
    }
    return clone;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Clamp the given elevation (of a token with a depth) to the elevation range of this Region.
   *
   * The elevation is clamped such that the head of the token is in the range if possible, but
   * the feet are never outside of the range.
   * @param {number} elevation    The elevation (of the token)
   * @param {number} [depth=0]    The depth of the token
   * @returns {number}            The clamped elevation
   */
  clampElevation(elevation, depth=0) {

    // Same code as in Level#clampElevation
    const depthDistance = depth * (this.parent?.grid ?? foundry.documents.BaseScene.defaultGrid).distance;
    return Math.clamp(
      Math.clamp(
        elevation + depthDistance,
        this.elevation.bottom,
        this.elevation.top
      ) - depthDistance,
      this.elevation.bottom,
      this.elevation.top
    );
  }

  /* -------------------------------------------- */
  /*  Shape Methods                               */
  /* -------------------------------------------- */

  /**
   * Test whether the given point is inside this Region.
   * @param {ElevatedPoint} point    The point.
   * @returns {boolean}              Is the point inside this Region?
   */
  testPoint(point) {
    return this.#testElevation(point.elevation) && this.polygonTree.testPoint(point);
  }

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

  /**
   * Test whether the given elevation is within the elevation range of this Region.
   * @param {number} elevation    The elevation.
   * @returns {boolean}           Is the elevation within the elevation range of this Region?
   */
  #testElevation(elevation) {
    return RegionDocument._testElevation(this.elevation, elevation);
  }

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

  /**
   * Test whether the given elevation is within the elevation range of this Region.
   * @param {{bottom: number; top: number; topInclusive: boolean}} elevationRange  The elevation range.
   * @param {number} elevation    The elevation.
   * @returns {boolean}           Is the elevation within the elevation range of this Region?
   * @internal
   */
  static _testElevation({bottom, top, topInclusive}, elevation) {
    if ( elevation < bottom ) return false;
    if ( elevation === bottom ) return true; // Flat regions
    if ( topInclusive ) return elevation <= top;
    return elevation < top;
  }

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

  /**
   * Create the Clipper polygon tree for this Region.
   * @param {BaseShapeData[]} shapes
   * @param {object[]|null} shapeConstraints
   * @returns {ClipperLib.PolyTree}
   * @internal
   */
  _createClipperPolyTree(shapes, shapeConstraints) {
    const i0 = shapes.findIndex(s => !s.hole);
    if ( i0 < 0 ) return new ClipperLib.PolyTree();
    if ( i0 === shapes.length - 1 ) {
      const shape = shapes[i0];
      if ( shape.hole ) return new ClipperLib.PolyTree();
      if ( !this.restriction.enabled ) return shape.clipperPolyTree;
      return this.#constrainShape(shape, shapeConstraints?.[i0], {polyTree: true});
    }
    const clipper = new ClipperLib.Clipper();
    const batches = this.#buildClipperBatches(shapes, shapeConstraints);
    if ( batches.length === 0 ) return new ClipperLib.PolyTree();
    if ( batches.length === 1 ) {
      const batch = batches[0];
      const tree = new ClipperLib.PolyTree();
      clipper.AddPaths(batch.paths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(batch.clipType, tree, ClipperLib.PolyFillType.pftNonZero, batch.fillType);
      return tree;
    }
    let subjectPaths = batches[0].paths;
    let subjectFillType = batches[0].fillType;
    for ( let i = 1; i < batches.length; i++ ) {
      const batch = batches[i];
      const solution = i === batches.length - 1 ? new ClipperLib.PolyTree() : [];
      clipper.Clear();
      clipper.AddPaths(subjectPaths, ClipperLib.PolyType.ptSubject, true);
      clipper.AddPaths(batch.paths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(batch.clipType, solution, subjectFillType, batch.fillType);
      subjectPaths = solution;
      subjectFillType = ClipperLib.PolyFillType.pftNonZero;
    }
    return subjectPaths;
  }

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

  /**
   * Build the Clipper batches.
   * @param {BaseShapeData[]} shapes
   * @param {object[]|null} shapeConstraints
   * @returns {{paths: ClipperLib.IntPoint[][]; fillType: ClipperLib.PolyFillType; clipType: ClipperLib.ClipType}[]}
   */
  #buildClipperBatches(shapes, shapeConstraints) {
    const batches = [];
    let i = 0;

    // Skip over holes at the beginning
    while ( i < shapes.length ) {
      if ( !shapes[i].hole ) break;
      i++;
    }

    // Iterate the shapes and batch paths of consecutive (non-)hole shapes
    while ( i < shapes.length ) {
      const paths = [];
      const isHole = shapes[i].hole;

      // Add paths of the current shape and following shapes until the next shape is (not) a hole
      do {
        const shape = shapes[i];
        let clipperPaths;
        if ( !this.restriction.enabled ) clipperPaths = shape.clipperPaths;
        else clipperPaths = this.#constrainShape(shape, shapeConstraints?.[i]);
        for ( const path of clipperPaths ) paths.push(path);
        i++;
      } while ( (i < shapes.length) && (shapes[i].hole === isHole) );

      // Create a batch from the paths, which are either all holes or all non-holes
      batches.push({
        paths,
        fillType: ClipperLib.PolyFillType.pftNonZero,
        clipType: isHole ? ClipperLib.ClipType.ctDifference : ClipperLib.ClipType.ctUnion
      });
    }
    return batches;
  }

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

  /**
   * Compute the restricted shape as ClipperPoint[] or PolyTree.
   * @param {BaseShapeData} shape                 The shape.
   * @param {number[]|undefined} constraint       The points of the shape constraint.
   * @param {object} [options]                    Additional options.
   * @param {boolean} [options.polyTree=false]    Return a PolyTree instead of ClipperPoint[]?
   * @returns {ClipperPoint[]|ClipperLib.PolyTree}
   */
  #constrainShape(shape, constraint, {polyTree=false}={}) {
    const solution = polyTree ? new ClipperLib.PolyTree() : [];
    if ( !constraint?.length ) return solution;
    const constraintPolygon = new PIXI.Polygon(constraint);
    const constraintPath = constraintPolygon.toClipperPoints({scalingFactor: CLIPPER_SCALING_FACTOR});
    const clipper = new ClipperLib.Clipper();
    clipper.AddPaths(shape.clipperPaths, ClipperLib.PolyType.ptSubject, true);
    clipper.AddPath(constraintPath, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return solution;
  }

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

  /**
   * Split the movement path into its segments.
   * @param {RegionSegmentizeMovementPathWaypoint[]} waypoints    The waypoints of movement.
   * @param {Point[]} samples                       The points relative to the waypoints that are tested.
   *                                                Whenever one of them is inside the region with respect to the
   *                                                `tolerance`, the moved object is considered to be inside the region.
   * @param {number} [tolerance=0]                  The tolerance of point containment (see `distance` parameter
   *                                                of {@link foundry.data.PolygonTree#testPoint}).
   * @returns {RegionMovementSegment[]}             The movement split into its segments.
   */
  segmentizeMovementPath(waypoints, samples, tolerance=0) {
    if ( samples.length === 0 ) return [];
    if ( waypoints.length === 2 ) return this.#segmentizeMovement(waypoints[0], waypoints[1], samples, tolerance);
    const segments = [];
    for ( let i = 1; i < waypoints.length; i++ ) {
      for ( const segment of this.#segmentizeMovement(waypoints[i - 1], waypoints[i], samples, tolerance) ) {
        segments.push(segment);
      }
    }
    return segments;
  }

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

  /**
   * Split the movement into its segments.
   * @param {RegionSegmentizeMovementPathWaypoint} origin         The origin of movement.
   * @param {RegionSegmentizeMovementPathWaypoint} destination    The destination of movement.
   * @param {Point[]} samples                                 The points relative to the waypoints that are tested.
   * @param {number} tolerance                                The tolerance.
   * @returns {RegionMovementSegment[]}                       The movement split into its segments.
   */
  #segmentizeMovement(origin, destination, samples, tolerance) {
    const originX = Math.round(origin.x);
    const originY = Math.round(origin.y);
    const originElevation = origin.elevation;
    const destinationX = Math.round(destination.x);
    const destinationY = Math.round(destination.y);
    const destinationElevation = destination.elevation;

    // If same origin and destination, there are no segments
    if ( (originX === destinationX) && (originY === destinationY)
      && (originElevation === destinationElevation) ) return [];

    let segments;

    // Teleportation
    const teleport = destination.teleport ?? false;
    if ( teleport ) {
      segments = this.#getTeleportationSegments(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples, tolerance);
    }

    // Movement with no elevation change
    else if ( originElevation === destinationElevation ) {
      segments = this.#getNoElevationChangeSegments(originX, originY, destinationX,
        destinationY, destinationElevation, samples, tolerance);
    }

    // Movement with elevation change
    else {
      segments = this.#getElevationChangeSegments(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples, tolerance);
    }

    // Add segment properties
    for ( const segment of segments ) segment.teleport = teleport;

    return segments;
  }

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

  /**
   * Get the teleporation segments from the origin to the destination.
   * @param {number} originX                  The x-coordinate of the origin.
   * @param {number} originY                  The y-coordinate of the origin.
   * @param {number} originElevation          The elevation of the destination.
   * @param {number} destinationX             The x-coordinate of the destination.
   * @param {number} destinationY             The y-coordinate of the destination.
   * @param {number} destinationElevation     The elevation of the destination.
   * @param {Point[]} samples                 The samples relative to the position.
   * @param {number} tolerance                The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getTeleportationSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation,
    samples, tolerance) {
    const positionChanged = (originX !== destinationX) || (originY !== destinationY);
    const elevationChanged = originElevation !== destinationElevation;
    if ( !(positionChanged || elevationChanged) ) return [];
    let originInside = this.#testElevation(originElevation);
    let destinationInside = this.#testElevation(destinationElevation);
    if ( positionChanged ) {
      originInside &&= this.#testSamples(originX, originY, samples, tolerance);
      destinationInside &&= this.#testSamples(destinationX, destinationY, samples, tolerance);
    } else if ( originInside || destinationInside ) {
      const inside = this.#testSamples(originX, originY, samples, tolerance);
      originInside &&= inside;
      destinationInside &&= inside;
    }
    let type;
    if ( originInside && destinationInside) type = REGION_MOVEMENT_SEGMENTS.MOVE;
    else if ( originInside ) type = REGION_MOVEMENT_SEGMENTS.EXIT;
    else if ( destinationInside ) type = REGION_MOVEMENT_SEGMENTS.ENTER;
    else return [];
    return [{
      type,
      from: {x: originX, y: originY, elevation: originElevation},
      to: {x: destinationX, y: destinationY, elevation: destinationElevation}
    }];
  }

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

  /**
   * Get the segments from the origin to the destination where both are at the same elevation.
   * If no elevation change, we don't have to deal with enter/exit segments at the bottom/top elevation range
   * @param {number} originX                  The x-coordinate of the origin.
   * @param {number} originY                  The y-coordinate of the origin.
   * @param {number} destinationX             The x-coordinate of the destination.
   * @param {number} destinationY             The y-coordinate of the destination.
   * @param {number} elevation                The elevation.
   * @param {Point[]} samples                 The samples relative to the position.
   * @param {number} tolerance                The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getNoElevationChangeSegments(originX, originY, destinationX, destinationY, elevation, samples, tolerance) {
    if ( !this.#testElevation(elevation) ) return [];
    return this.#getMovementSegments(originX, originY, elevation, destinationX, destinationY, elevation, samples,
      tolerance);
  }

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

  /**
   * Get the segments from the origin to the destination where both are at different elevations.
   * @param {number} originX                  The x-coordinate of the origin.
   * @param {number} originY                  The y-coordinate of the origin.
   * @param {number} originElevation          The elevation of the destination.
   * @param {number} destinationX             The x-coordinate of the destination.
   * @param {number} destinationY             The y-coordinate of the destination.
   * @param {number} destinationElevation     The elevation of the destination.
   * @param {Point[]} samples                 The samples relative to the position.
   * @param {number} tolerance                The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getElevationChangeSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation,
    samples, tolerance) {

    // Calculate the first and last elevation within the elevation range of this Region
    const {bottom, top, topInclusive} = this.elevation;
    const upwards = originElevation < destinationElevation;
    const e1 = upwards ? Math.max(originElevation, bottom) : Math.min(originElevation, top);
    const e2 = upwards ? Math.min(destinationElevation, top) : Math.max(destinationElevation, bottom);
    const t1 = (e1 - originElevation) / (destinationElevation - originElevation);
    const t2 = (e2 - originElevation) / (destinationElevation - originElevation);

    // Return if there's no intersection
    if ( t1 > t2 ) return [];

    // Calculate the first and last position of movement in the elevation range of this Region
    const x1 = Math.round(Math.mix(originX, destinationX, t1));
    const y1 = Math.round(Math.mix(originY, destinationY, t1));
    const x2 = Math.round(Math.mix(originX, destinationX, t2));
    const y2 = Math.round(Math.mix(originY, destinationY, t2));

    // Adjust elevations based on top inclusion
    let e1Adjusted = e1;
    let e2Adjusted = e2;
    if ( !topInclusive && (bottom < top) ) {
      if ( upwards ) {
        if ( e2 === top ) {
          e2Adjusted = Math.max(Math.nextDown(e2), originElevation);
          if ( e1 === e2 ) e1Adjusted = e2Adjusted;
        }
      } else {
        if ( e1 === top ) {
          e1Adjusted = Math.max(Math.nextDown(e1), destinationElevation);
          if ( e1 === e2 ) e2Adjusted = e1Adjusted;
        }
      }
    }

    // Get movements segments within the elevation range of this Region
    const segments = this.#getMovementSegments(x1, y1, e1Adjusted, x2, y2, e2Adjusted, samples, tolerance);

    // Add segment if we enter vertically
    if ( (originElevation !== e1Adjusted) && this.#testElevation(e1Adjusted)
      && this.#testSamples(x1, y1, samples, tolerance) ) {
      let fromElevation;
      if ( upwards ) fromElevation = Math.max(Math.nextDown(e1), originElevation);
      else if ( topInclusive ) fromElevation = Math.min(Math.nextUp(e1), originElevation);
      else fromElevation = e1;
      segments.unshift({
        type: REGION_MOVEMENT_SEGMENTS.ENTER,
        from: {x: x1, y: y1, elevation: fromElevation},
        to: {x: x1, y: y1, elevation: e1Adjusted}
      });
    }

    // Add segment if we exit vertically
    if ( (destinationElevation !== e2Adjusted) && this.#testElevation(e2Adjusted)
      && this.#testSamples(x2, y2, samples, tolerance) ) {
      let toElevation;
      if ( !upwards ) toElevation = Math.max(Math.nextDown(e2), destinationElevation);
      else if ( topInclusive ) toElevation = Math.min(Math.nextUp(e2), destinationElevation);
      else toElevation = e2;
      segments.push({
        type: REGION_MOVEMENT_SEGMENTS.EXIT,
        from: {x: x2, y: y2, elevation: e2Adjusted},
        to: {x: x2, y: y2, elevation: toElevation}
      });
    }

    return segments;
  }

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

  /**
   * Test whether one of the samples relative to the given position is contained within this Region.
   * @param {number} x             The x-coordinate of the position.
   * @param {number} y             The y-coordinate of the position.
   * @param {Point[]} samples      The samples relative to the position.
   * @param {number} tolerance     The tolerance.
   * @returns {boolean}            Is one of the samples contained within this Region?
   */
  #testSamples(x, y, samples, tolerance) {
    const point = RegionDocument.#SHARED_POINT;
    const n = samples.length;
    for ( let i = 0; i < n; i++ ) {
      const sample = samples[i];
      point.x = x + sample.x;
      point.y = y + sample.y;
      if ( this.#polygonTree.testPoint(point, tolerance) ) return true;
    }
    return false;
  }

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

  /**
   * Split the movement into its segments.
   * @param {number} originX               The x-coordinate of the origin.
   * @param {number} originY               The y-coordinate of the origin.
   * @param {number} originElevation       The elevation of the destination, which must be in the elevation range.
   * @param {number} destinationX          The x-coordinate of the destination.
   * @param {number} destinationY          The y-coordinate of the destination.
   * @param {number} destinationElevation  The elevation of the destination, which must be in the elevation range.
   * @param {Point[]} samples              The samples relative to the position.
   * @param {number} tolerance             The tolerance.
   * @returns {{type: REGION_MOVEMENT_SEGMENTS; from: ElevatedPoint; to: ElevatedPoint}[]}
   */
  #getMovementSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation, samples,
    tolerance) {
    const segments = [];
    if ( (originX === destinationX) && (originY === destinationY) ) {

      // Add move segment if inside and the elevation changed
      if ( (originElevation !== destinationElevation) && this.#testSamples(originX, originY, samples, tolerance) ) {
        segments.push({
          type: REGION_MOVEMENT_SEGMENTS.MOVE,
          from: {x: originX, y: originY, elevation: originElevation},
          to: {x: destinationX, y: destinationY, elevation: destinationElevation}
        });
      }
      return segments;
    }

    // Test first if the bounds of the movement overlap the bounds of this Region
    if ( !this.#couldMovementIntersect(originX, originY, destinationX, destinationY, samples, tolerance) ) {
      return segments;
    }

    // Compute the intervals
    const intervals = this.#computeSegmentIntervals(originX, originY, destinationX, destinationY, samples, tolerance);

    // Compute the segments from the intervals
    for ( const {start, end} of intervals ) {

      // Find crossings (enter and exit) for the interval
      const startX = Math.round(Math.mix(originX, destinationX, start));
      const startY = Math.round(Math.mix(originY, destinationY, start));
      const startElevation = Math.mix(originElevation, destinationElevation, start);
      const endX = Math.round(Math.mix(originX, destinationX, end));
      const endY = Math.round(Math.mix(originY, destinationY, end));
      const endElevation = Math.mix(originElevation, destinationElevation, end);
      const [{x: x00, y: y00, inside: inside00}, {x: x01, y: y01, inside: inside01}] = this.#findBoundaryCrossing(
        originX, originY, startX, startY, endX, endY, samples, tolerance, true);
      const [{x: x10, y: y10, inside: inside10}, {x: x11, y: y11, inside: inside11}] = this.#findBoundaryCrossing(
        startX, startY, endX, endY, destinationX, destinationY, samples, tolerance, false);

      // Add enter segment if found
      if ( inside00 !== inside01 ) {
        segments.push({
          type: REGION_MOVEMENT_SEGMENTS.ENTER,
          from: {x: x00, y: y00, elevation: startElevation},
          to: {x: x01, y: y01, elevation: startElevation}
        });
      }

      // Add move segment or enter/exit segment if not completely inside
      if ( (inside01 || inside10) && ((x01 !== x10) || (y01 !== y10)) ) {
        segments.push({
          type: inside01 && inside10 ? REGION_MOVEMENT_SEGMENTS.MOVE
            : inside10 ? REGION_MOVEMENT_SEGMENTS.ENTER : REGION_MOVEMENT_SEGMENTS.EXIT,
          from: {x: x01, y: y01, elevation: startElevation},
          to: {x: x10, y: y10, elevation: endElevation}
        });
      }

      // Add exit segment if found
      if ( inside10 !== inside11 ) {
        segments.push({
          type: REGION_MOVEMENT_SEGMENTS.EXIT,
          from: {x: x10, y: y10, elevation: endElevation},
          to: {x: x11, y: y11, elevation: endElevation}
        });
      }
    }

    // Make sure we have segments for origins/destinations inside the region
    const originInside = this.#testSamples(originX, originY, samples, tolerance);
    const destinationInside = this.#testSamples(destinationX, destinationY, samples, tolerance);

    // If neither the origin nor the destination are inside, we are done
    if ( !originInside && !destinationInside ) return segments;

    // If we didn't find segments with the method above, we need to add segments for the origin and/or destination
    if ( segments.length === 0 ) {

      // If the origin is inside, look for a crossing (exit) after the origin
      if ( originInside ) {
        const [{x: x0, y: y0}, {x: x1, y: y1, inside: inside1}] = this.#findBoundaryCrossing(
          originX, originY, originX, originY, destinationX, destinationY, samples, tolerance, false);
        if ( !inside1 ) {

          // If we don't exit at the origin, add a move segment
          if ( (originX !== x0) || (originY !== y0) ) {
            segments.push({
              type: REGION_MOVEMENT_SEGMENTS.MOVE,
              from: {x: originX, y: originY, elevation: originElevation},
              to: {x: x0, y: y0, elevation: originElevation}
            });
          }

          // Add the exit segment that we found
          segments.push({
            type: REGION_MOVEMENT_SEGMENTS.EXIT,
            from: {x: x0, y: y0, elevation: originElevation},
            to: {x: x1, y: y1, elevation: originElevation}
          });
        }
      }

      // If the destination is inside, look for a crossing (enter) before the destination
      if ( destinationInside ) {
        const [{x: x0, y: y0, inside: inside0}, {x: x1, y: y1}] = this.#findBoundaryCrossing(
          originX, originY, destinationX, destinationY, destinationX, destinationY, samples, tolerance, true);
        if ( !inside0 ) {

          // Add the enter segment that we found
          segments.push({
            type: REGION_MOVEMENT_SEGMENTS.ENTER,
            from: {x: x0, y: y0, elevation: destinationElevation},
            to: {x: x1, y: y1, elevation: destinationElevation}
          });

          // If we don't enter at the destination, add a move segment
          if ( (destinationX !== x1) || (destinationY !== y1) ) {
            segments.push({
              type: REGION_MOVEMENT_SEGMENTS.MOVE,
              from: {x: x1, y: y1, elevation: destinationElevation},
              to: {x: destinationX, y: destinationY, elevation: destinationElevation}
            });
          }
        }
      }

      // If both are inside and we didn't find we didn't find a crossing, the entire segment is contained
      if ( originInside && destinationInside && (segments.length === 0) ) {
        segments.push({
          type: REGION_MOVEMENT_SEGMENTS.MOVE,
          from: {x: originX, y: originY, elevation: originElevation},
          to: {x: destinationX, y: destinationY, elevation: destinationElevation}
        });
      }
    }

    // We have segments and know we make sure that the origin and/or destination that are inside are
    // part of those segments. If they are not we either need modify the first/last segment or add
    // segments to the beginning/end.
    else {

      // Make sure we have a segment starting at the origin if it is inside
      if ( originInside ) {
        const first = segments.at(0);
        const {x: firstX, y: firstY} = first.from;
        if ( (originX !== firstX) || (originY !== firstY) ) {

          // The first segment is an enter segment, so we need to add an exit segment before this one
          if ( first.type === 1 ) {
            const [{x: x0, y: y0}, {x: x1, y: y1}] = this.#findBoundaryCrossing(
              firstX, firstY, originX, originY, originX, originY, samples, tolerance, false);
            segments.unshift({
              type: REGION_MOVEMENT_SEGMENTS.EXIT,
              from: {x: x0, y: y0, elevation: originElevation},
              to: {x: x1, y: y1, elevation: originElevation}
            });
          }

          // We have an exit or move segment, in which case we can simply update the from position
          else {
            first.from.x = originX;
            first.from.y = originY;
          }
        }
      }

      // Make sure we have a segment ending at the destination if it is inside
      if ( destinationInside ) {
        const last = segments.at(-1);
        const {x: lastX, y: lastY} = last.to;
        if ( (destinationX !== lastX) || (destinationY !== lastY) ) {

          // The last segment is an exit segment, so we need to add an enter segment after this one
          if ( last.type === -1 ) {
            const [{x: x0, y: y0}, {x: x1, y: y1}] = this.#findBoundaryCrossing(
              lastX, lastY, destinationX, destinationY, destinationX, destinationY, samples, tolerance, true);
            segments.push({
              type: REGION_MOVEMENT_SEGMENTS.ENTER,
              from: {x: x0, y: y0, elevation: destinationElevation},
              to: {x: x1, y: y1, elevation: destinationElevation}
            });
          }

          // We have an enter or move segment, in which case we can simply update the to position
          else {
            last.to.x = destinationX;
            last.to.y = destinationY;
          }
        }
      }
    }
    return segments;
  }

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

  /**
   * Test whether the movement could intersect this Region.
   * @param {number} originX           The x-coordinate of the origin.
   * @param {number} originY           The y-coordinate of the origin.
   * @param {number} destinationX      The x-coordinate of the destination.
   * @param {number} destinationY      The y-coordinate of the destination.
   * @param {Point[]} samples          The samples relative to the position.
   * @param {number} tolerance         The tolerance.
   * @returns {boolean}                Could the movement intersect?
   */
  #couldMovementIntersect(originX, originY, destinationX, destinationY, samples, tolerance) {
    let {x: minX, y: minY} = samples[0];
    let maxX = minX;
    let maxY = minY;
    for ( let i = 1; i < samples.length; i++ ) {
      const {x, y} = samples[i];
      minX = Math.min(minX, x);
      minY = Math.min(minY, y);
      maxX = Math.max(maxX, x);
      maxY = Math.max(maxY, y);
    }
    minX += Math.min(originX, destinationX);
    minY += Math.min(originY, destinationY);
    maxX += Math.max(originX, destinationX);
    maxY += Math.max(originY, destinationY);
    const {left, right, top, bottom} = this.bounds;
    return (Math.max(minX, left - 1) <= Math.min(maxX, right + 1))
      && (Math.max(minY, top - 1) <= Math.min(maxY, bottom + 1));
  }

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

  /**
   * Compute the intervals of intersection of the movement.
   * @param {number} originX                      The x-coordinate of the origin.
   * @param {number} originY                      The y-coordinate of the origin.
   * @param {number} destinationX                 The x-coordinate of the destination.
   * @param {number} destinationY                 The y-coordinate of the destination.
   * @param {Point[]} samples                     The samples relative to the position.
   * @param {number} tolerance                    The tolerance.
   * @returns {{start: number; end: number}[]}    The intervals where we have an intersection.
   */
  #computeSegmentIntervals(originX, originY, destinationX, destinationY, samples, tolerance) {
    const intervals = [];
    const clipper = new ClipperLib.Clipper();
    const solution = new ClipperLib.PolyTree();
    const origin = new ClipperLib.IntPoint(0, 0);
    const destination = new ClipperLib.IntPoint(0, 0);
    const lineSegment = [origin, destination];

    // Calculate the intervals for each of the line segments
    for ( const {x: dx, y: dy} of samples ) {
      origin.X = Math.round((originX + dx) * CLIPPER_SCALING_FACTOR);
      origin.Y = Math.round((originY + dy) * CLIPPER_SCALING_FACTOR);
      destination.X = Math.round((destinationX + dx) * CLIPPER_SCALING_FACTOR);
      destination.Y = Math.round((destinationY + dy) * CLIPPER_SCALING_FACTOR);

      // Intersect the line segment with the geometry of this Region
      clipper.Clear();
      clipper.AddPath(lineSegment, ClipperLib.PolyType.ptSubject, false);
      clipper.AddPaths(this.clipperPaths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);

      // Calculate the intervals of the intersections
      const length = Math.hypot(destination.X - origin.X, destination.Y - origin.Y);
      for ( const [a, b] of ClipperLib.Clipper.PolyTreeToPaths(solution) ) {
        let start = Math.hypot(a.X - origin.X, a.Y - origin.Y) / length;
        let end = Math.hypot(b.X - origin.X, b.Y - origin.Y) / length;
        if ( start > end ) [start, end] = [end, start];
        intervals.push({start, end});
      }
    }

    // Sort and merge intervals
    intervals.sort((i0, i1) => i0.start - i1.start);
    const mergedIntervals = [];
    if ( intervals.length !== 0 ) {
      let i0 = intervals[0];
      mergedIntervals.push(i0);
      for ( let i = 1; i < intervals.length; i++ ) {
        const i1 = intervals[i];
        if ( i0.end < i1.start ) mergedIntervals.push(i0 = i1);
        else i0.end = Math.max(i0.end, i1.end);
      }
    }
    return mergedIntervals;
  }

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

  /**
   * Find the crossing (enter or exit) at the current position between the start and end position, if possible.
   * The current position should be very close to crossing, otherwise we test a lot of pixels potentially.
   * We use Bresenham's line algorithm to walk forward/backwards to find the crossing.
   * @see {@link https://en.wikipedia.org/wiki/Bresenham's_line_algorithm}
   * @param {number} startX      The start x-coordinate.
   * @param {number} startY      The start y-coordinate.
   * @param {number} currentX    The current x-coordinate.
   * @param {number} currentY    The current y-coordinate.
   * @param {number} endX        The end x-coordinate.
   * @param {number} endY        The end y-coordinate.
   * @param {boolean} samples    The samples.
   * @param {number} tolerance   The tolerance.
   * @param {boolean} enter      Find enter? Otherwise find exit.
   * @returns {[from: {x: number; y: number; inside: boolean}; to: {x: number; y: number; inside: boolean}]}
   */
  #findBoundaryCrossing(startX, startY, currentX, currentY, endX, endY, samples, tolerance, enter) {
    if ( tolerance !== 0 ) {
      return [
        this.#findNextInteriorExteriorPosition(currentX, currentY, startX, startY, samples, tolerance, !enter),
        this.#findNextInteriorExteriorPosition(currentX, currentY, endX, endY, samples, tolerance, enter)
      ];
    }

    let x0 = currentX;
    let y0 = currentY;
    let x1 = x0;
    let y1 = y0;
    let x2;
    let y2;

    // Adjust starting conditions depending on whether we are already inside the Region
    const inside = this.#testSamples(currentX, currentY, samples, 0);
    if ( inside === enter ) {
      x2 = startX;
      y2 = startY;
    } else {
      x2 = endX;
      y2 = endY;
    }
    const sx = x1 < x2 ? 1 : -1;
    const sy = y1 < y2 ? 1 : -1;
    const dx = Math.abs(x1 - x2);
    const dy = 0 - Math.abs(y1 - y2);
    let e = dx + dy;

    // Iterate until we find a crossing point or we reach the start/end position
    while ( (x1 !== x2) || (y1 !== y2) ) {
      const e2 = e * 2;
      if ( e2 <= dx ) {
        e += dx;
        y1 += sy;
      }
      if ( e2 >= dy ) {
        e += dy;
        x1 += sx;
      }

      // If we found the crossing, return it
      if ( this.#testSamples(x1, y1, samples, 0) !== inside ) {
        return inside === enter
          ? [{x: x1, y: y1, inside: !inside}, {x: x0, y: y0, inside}]
          : [{x: x0, y: y0, inside}, {x: x1, y: y1, inside: !inside}];
      }

      x0 = x1;
      y0 = y1;
    }
    return [{x: x1, y: y1, inside}, {x: x1, y: y1, inside}];
  }

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

  /**
   * Find the next interior/exterior position along the line from (x0, y0) to (x1, y1).
   * @param {number} x0          The start x-coordinate.
   * @param {number} y0          The start y-coordinate.
   * @param {number} x1          The end x-coordinate.
   * @param {number} y1          The end y-coordinate.
   * @param {boolean} samples    The samples.
   * @param {number} tolerance   The tolerance.
   * @param {boolean} inside     Find first interior point? Otherwise find first exterior point.
   * @returns {{x: number; y: number; inside: boolean}}
   */
  #findNextInteriorExteriorPosition(x0, y0, x1, y1, samples, tolerance, inside) {
    if ( this.#testSamplesInteriorExterior(x0, y0, samples, tolerance, inside) ) return {x: x0, y: y0, inside};
    const sx = x0 < x1 ? 1 : -1;
    const sy = y0 < y1 ? 1 : -1;
    const dx = Math.abs(x0 - x1);
    const dy = 0 - Math.abs(y0 - y1);
    let e = dx + dy;
    while ( (x0 !== x1) || (y0 !== y1) ) {
      const e2 = e * 2;
      if ( e2 <= dx ) {
        e += dx;
        y0 += sy;
      }
      if ( e2 >= dy ) {
        e += dy;
        x0 += sx;
      }
      if ( this.#testSamplesInteriorExterior(x0, y0, samples, tolerance, inside) ) return {x: x0, y: y0, inside};
    }
    return {x: x1, y: y1, inside: this.#testSamples(x1, y1, samples, tolerance)};
  }

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

  /**
   * Test whether one/all of the samples relative to the given position is/are in the interior/exterior of this Region.
   * @param {number} x             The x-coordinate of the position.
   * @param {number} y             The y-coordinate of the position.
   * @param {Point[]} samples      The samples relative to the position.
   * @param {number} tolerance     The tolerance.
   * @param {boolean} inside       If true, test whether at least one point is in the interior, if false, test
   *                               whether all points are in the exterior.
   * @returns {boolean}            Is one of the samples contained within this Region?
   */
  #testSamplesInteriorExterior(x, y, samples, tolerance, inside) {
    const point = RegionDocument.#SHARED_POINT;
    const radius = Math.abs(tolerance);
    const n = samples.length;
    for ( let i = 0; i < n; i++ ) {
      const sample = samples[i];
      point.x = x + sample.x;
      point.y = y + sample.y;
      const result = this.#polygonTree.testCircle(point, radius);
      if ( result === 0 ) return false;
      if ( result === 1 ) return inside;
    }
    return !inside;
  }

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

  /**
   * Update the shape constraints of this Region. If `save` is true, the shape constraints are updated
   * only if the current User is designated for it.
   * @param {object} [options]                Additional options
   * @param {boolean} [options.save=false]    Persist the shape constraints changes? Default: `false`.
   */
  updateShapeConstraints({save=false}={}) {
    if ( save && !this.persisted ) throw new Error("A nonpersisted Document cannot be updated.");
    if ( !this._source.restriction.enabled && !this._source._shapeConstraints ) return;
    if ( save ) this.parent._updateRegionShapeConstraints(this);
    else this.updateSource({_shapeConstraints: this._computeShapeConstraints()});
  }

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

  /**
   * Update the point sources of this Region document.
   * @param {object} [changes]     The changes that will be applied to this Region.
   * @returns {number[][]|null}    The computed shape constraint for each shape, if restricted/possible.
   * @internal
   */
  _computeShapeConstraints(changes={}) {
    const enabled = changes.restriction?.enabled !== undefined ? changes.restriction.enabled
      : this.restriction.enabled;
    if ( !enabled ) return null;

    // Compute the point sources if possible, i.e. the region is viewed
    const levels = changes.levels !== undefined ? changes.levels : this._source.levels;
    if ( levels.length !== 1 ) return null;
    const level = this.parent.levels.get(levels[0]);
    if ( !level?.isView ) return null;

    const type = changes.restriction?.type !== undefined ? changes.restriction.type : this.restriction.type;
    const priority = changes.restriction?.priority !== undefined ? changes.restriction.priority
      : this.restriction.priority;
    const shapes = changes.shapes !== undefined
      ? changes.shapes.map(s => s instanceof BaseShapeData ? s : new BaseShapeData.TYPES[s.type](s, {parent: this}))
      : this.shapes;

    const constraints = shapes.map(_ => undefined);
    const toCompute = {};
    for ( const [i, shape] of shapes.entries() ) {
      const x = Math.round(shape.origin.x);
      const y = Math.round(shape.origin.y);
      const key = `${x},${y}`;
      if ( toCompute[key] ) {
        toCompute[key].boundingBox.enlarge(shape.bounds);
        toCompute[key].indices.push(i);
      } else {
        toCompute[key] = {origin: {x, y}, boundingBox: shape.bounds.clone(), indices: [i]};
      }
    }
    for ( const {origin, boundingBox, indices} of Object.values(toCompute) ) {
      const config = {
        type,
        level,
        priority,
        edgeTypes: {wall: true, outerBounds: true},
        boundingBox: boundingBox.fit(level.parent.dimensions.rect).ceil(),
        useThreshold: true
      };
      let suppressed = false;
      if ( level.isView ) {
        if ( ["light", "sight"].includes(type) ) {
          const condition = darknessSource => priority <= darknessSource.priority;
          suppressed = canvas.effects.testInsideDarkness(origin, {condition});
          config.edgeTypes.source = true;
        } else if ( type === "darkness" ) {
          const condition = lightSource => priority < lightSource.priority;
          suppressed = canvas.effects.testInsideLight(origin, {condition});
          config.edgeTypes.source = true;
        }
      }
      if ( suppressed ) config.radius = 0;
      const polygon = this._computeShapeConstraint(origin, config);
      for ( const i of indices ) constraints[i] = polygon.points;
    }
    return constraints;
  }

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

  /**
   * Compute the shape constraint for the given origin and config.
   * @param {ElevatedPoint} origin               The origin of the constraint.
   * @param {PointSourcePolygonConfig} config    The config of the constraint.
   * @returns {PointSourcePolygon}               The shape constraint.
   * @protected
   */
  _computeShapeConstraint(origin, config) {
    const polygonBackend = CONFIG.Canvas.polygonBackends[config.type];
    return polygonBackend.create(origin, config);
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preCreateOperation(documents, operation, user) {
    const allowed = await super._preCreateOperation(documents, operation, user);
    if ( allowed === false ) return false;

    // Compute point sources if possible
    for ( const document of documents ) {
      document.updateSource({_shapeConstraints: document._computeShapeConstraints()});
    }
  }

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

  /** @inheritDoc */
  static async _preUpdateOperation(documents, operation, user) {
    const allowed = await super._preUpdateOperation(documents, operation, user);
    if ( allowed === false ) return false;

    // Compute point sources if possible
    for ( let i = 0; i < documents.length; i++ ) {
      const document = documents[i];
      const changes = operation.updates[i];
      if ( ("shapes" in changes) || ("elevation" in changes) || (("restriction" in changes) && (("enabled" in changes.restriction)
          || (document.restriction.enabled && (("type" in changes.restriction) || ("priority" in changes.restriction))))) ) {
        changes._shapeConstraints = document._computeShapeConstraints(changes);
        operation._updateShapeConstraints ??= {};
        operation._updateShapeConstraints[document.id] = true;
      }
      if ( "token" in DataFieldOperator.get(changes.attachment ?? {}) ) {
        operation._priorAttachedToken = {};
        operation._priorAttachedToken[document.id] = document._source.attachment.token;
      }
    }
  }

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

  /** @inheritDoc */
  _updateCommit(copy, diff, options, state) {
    super._updateCommit(copy, diff, options, state);
    if ( ("shapes" in diff) || ("_shapeConstraints" in diff) ) this._clearPolygonTree();
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.attachment.token?.attachments.regions.add(this);
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update attached token
    if ( "token" in DataFieldOperator.get(changed.attachment ?? {}) ) {
      const priorAttachedToken = options._priorAttachedToken?.[this.id];
      if ( priorAttachedToken ) this.parent.tokens.get(priorAttachedToken)?.attachments.regions.delete(this);
      this.attachment.token?.attachments.regions.add(this);
    }

    // Close all open shape configs for this region if the shapes are changed
    if ( "shapes" in changed ) {
      this.#regionShapes = undefined;
      for ( const app of foundry.applications.apps.ShapeConfig.instances() ) {
        if ( app.document === this ) app.close();
      }

      // Clear the shape clipboard if a shape of this region was cut
      const shapeClipboard = this.layer?._shapeClipboard;
      if ( shapeClipboard && (shapeClipboard.shape?.parent === this) && shapeClipboard.cut ) {
        shapeClipboard.shape = null;
      }
    }

    // If the user that updated the shapes could not compute the constraints (indicated by the null),
    // the current user may be able to correct it if they view the scene and are designated for the region
    if ( this.restriction.enabled && (DataFieldOperator.get(changed._shapeConstraints) === null) ) {
      this.updateShapeConstraints({save: true});
    }
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.attachment.token?.attachments.regions.delete(this);
  }

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

  /**
   * Called when the scene's grid is changed.
   * @param {object} changed   The changes to the grid.
   * @internal
   */
  _onGridChange(changed) {
    if ( !(("type" in changed) || ("size" in changed)) ) return;

    // The geometry needs to be recomputed if one shape is grid-based
    const affectedByGrid = this.shapes.some(shape => shape.isAffectedByGrid);
    if ( affectedByGrid ) this._clearPolygonTree();
  }

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

  /**
   * Clear the polygon tree.
   * @internal
   */
  _clearPolygonTree() {
    this.#polygonTree = undefined;
    this.#clipperPolyTree = undefined;
    this._onPolygonTreeChange();
  }

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

  /**
   * Called when the polygon tree of the Region has changed.
   * @protected
   */
  _onPolygonTreeChange() {}

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

  /** @inheritDoc */
  async _refreshViewedState() {
    const wasViewed = this.rendered;
    await super._refreshViewedState();
    const isViewed = this.rendered;
    if ( isViewed === wasViewed ) return;
    if ( isViewed ) {
      // noinspection ES6MissingAwait
      this._handleEvent({
        name: CONST.REGION_EVENTS.BEHAVIOR_VIEWED,
        data: {},
        region: this,
        user: game.user
      });
      /** @deprecated since v13 */
      // noinspection ES6MissingAwait
      this._handleEvent({name: "behaviorStatus", data: {viewed: true}, region: this, user: game.user});
    } else {
      // noinspection ES6MissingAwait
      this._handleEvent({
        name: CONST.REGION_EVENTS.BEHAVIOR_UNVIEWED,
        data: {},
        region: this,
        user: game.user
      });
      /** @deprecated since v13 */
      // noinspection ES6MissingAwait
      this._handleEvent({name: "behaviorStatus", data: {viewed: false}, region: this, user: game.user});
    }
  }

  /* -------------------------------------------- */
  /*  API Methods                                 */
  /* -------------------------------------------- */

  /**
   * Create an emanation Region for the Token and attach it to the Token.
   * @param {TokenDocument} token                    The Token to attach the emanation Region to
   * @param {number} range                           The range of the emanation in grid units
   * @param {Omit<RegionData, "shapes"|"elevation">} regionData   The Region data of the emanation
   * @param {options} [options]                      Additional options
   * @param {boolean} [options.excludeToken=false]   Exclude the Token's own shape from the area of the emanation?
   * @param {boolean} [options.gridBased=false]      Should the emanation conform to the grid's metric?
   * @param {Partial<Omit<DatabaseCreateOperation, "parent">>} [options.createOptions]  Optional creation options
   * @returns {Promise<RegionDocument|void>}         The created Region document unless the creation was prevented
   */
  static async createTokenEmanation(token, range, regionData, {excludeToken=false, gridBased=false,
    createOptions={}}={}) {
    if ( !token.persisted ) throw new Error("The Token must be persisted.");
    const {x, y, elevation, width, height, depth, shape, level} = token._source;
    const {distance, distancePixels} = token.parent.dimensions;
    const radius = range * distancePixels;
    return this.create({
      ...regionData,
      shapes: [{
        type: "emanation",
        base: {type: "token", x, y, width, height, shape, hole: excludeToken},
        radius,
        gridBased
      }],
      elevation: {bottom: elevation - range, top: elevation + (depth * distance) + range},
      attachment: {token: token.id},
      levels: [level]
    }, {...createOptions, parent: token.parent, pack: token.pack});
  }

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

  /**
   * Teleport a Token into this Region.
   *
   * The Token may be in the same Scene as this Region, or in a different Scene.
   * The current User must be an owner of the Token Document in order to teleport it.
   * For teleportation to a different Scene the current User requires `TOKEN_CREATE` and
   * `TOKEN_DELETE` permissions. If the Token is teleported to different Scene, it is deleted
   * and a new Token Document in the other Scene is created.
   *
   * This function can work with ephemeral (non-persisted) Region documents.
   * @param {TokenDocument} token              An existing Token Document to teleport.
   * @param {object} [options]                 Additional options.
   * @param {"random"|"center"|"relative"} [options.placement="random"]  The placement. Default: `"random"`.
   * @param {boolean} [options.snap=true]      Attempt to teleport the tokens to a snapped position. Default: `true`.
   * @param {Point} [options.offset]           The relative offset position.
   * @param {boolean} [options.avoidOccupied=true]  Avoid occupied grid spaces when placing randomly with snapping.
   *                                                Default: `true`.
   * @param {boolean|TokenPanningOptions} [options.pan=true]
   *   Pan the canvas (with transition animation) to the destination if the token is controlled? Default: `true`.
   * @param {Partial<TokenData>} [options.updateData]   Additonal Token update data.
   * @returns {Promise<TokenDocument>}         The same Token Document if teleported within the same Scene,
   *                                           or a new Token Document if teleported to a different Scene
   * @throws {Error}                           If failed to teleport the Token document.
   * @example Teleport a single token with random rotation
   * ```js
   * await region.teleportToken(token, {
   *   updateData: {
   *     rotation: Math.random() * 360
   *   }
   * });
   * ```
   */
  async teleportToken(token, options) {
    if ( options?.updateData ) options = {...options, updateData: new Map([[token, options.updateData]])};
    const tokenMap = await this.teleportTokens([token], options);
    return tokenMap.get(token) ?? token;
  }

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

  /**
   * Teleport Tokens into this Region.
   *
   * The Tokens may be in the same Scene as this Region, or in a different Scene.
   * The current User must be an owner of the Token Documents in order to teleport them.
   * For teleportation to a different Scene the current User requires `TOKEN_CREATE` and
   * `TOKEN_DELETE` permissions. If a Token is teleported to different Scene, it is deleted
   * and a new Token Document in the other Scene is created.
   *
   * This function can work ephemeral (non-persisted) Region documents.
   * @param {Iterable<TokenDocument>} tokens   Existing Token Documents to teleport.
   * @param {object} [options]                 Additional options.
   * @param {"random"|"center"|"relative"} [options.placement="random"]  The placement. Default: `"random"`.
   * @param {boolean} [options.snap=true]      Attempt to teleport the tokens to a snapped position. Default: `true`.
   * @param {Point} [options.offset]           The relative offset position.
   * @param {boolean} [options.avoidOccupied=true]  Avoid occupied grid spaces when placing randomly with snapping.
   *                                                Default: `true`.
   * @param {string} [options.level]                The destination Level ID, which must be a Level this Region is in.
   *                                                Default: the Level of the Region if it is in only one Level.
   * @param {boolean|TokenPanningOptions} [options.pan=true]
   *   Pan the canvas (with transition animation) to the destination if the token is controlled? Default: `true`.
   * @param {Map<TokenDocument, Partial<TokenData>>} [options.updateData]  Additonal update data.
   * @returns {Promise<Map<TokenDocument, TokenDocument>>}  The mapping of deleted to created Token Documents.
   * @throws {Error}                           If the current User doesn't have the necessary permissions, Token
   *                                           Documents could not be created/updated/deleted or there's no valid
   *                                           placement.
   * @example Teleport multiple tokens with random rotation
   * ```js
   * const updateData = new Map();
   * for ( const token of tokens ) {
   *   updateData.set(token, {
   *     rotation: Math.random() * 360
   *   });
   * }
   * await region.teleportTokens(tokens, {updateData});
   * ```
   */
  async teleportTokens(tokens, {placement="random", snap=true, offset, avoidOccupied=true, level, pan, updateData}={}) {
    if ( this.inCompendium ) throw new Error("The Region document must not be in a compendium.");
    const destinationScene = this.parent;
    if ( !Array.isArray(tokens) ) tokens = [...tokens];
    if ( tokens.length === 0 ) return [];

    // Get the destination tokens
    const destinationTokens = tokens.map(token => {
      if ( token.parent === destinationScene ) return token;
      return token.clone({}, {parent: destinationScene});
    });

    // Get a destination for each token
    const destinations = this.#getTokenDestinations(destinationTokens, {placement, snap, offset, avoidOccupied, level});

    // Collect taken token IDs
    const takenIds = new Set(destinationScene.tokens.keys());
    for ( const token of tokens ) takenIds.add(token.id);

    // Build update, creation, and deletion data
    const toUpdate = [];
    const movement = {};
    const toCreate = [];
    const toDelete = new Map();
    const newUuids = new Map();
    for ( const [i, originToken] of tokens.entries() ) {
      if ( originToken.inCompendium ) throw new Error("The Token document must not be in a compendium.");
      if ( !originToken.isOwner ) throw new Error("You must be an owner of the Tokens in order to teleport them.");
      const originScene = originToken.parent;
      if ( (originScene !== destinationScene) && !(game.user.can("TOKEN_CREATE") && game.user.can("TOKEN_DELETE")) ) {
        throw new Error("You must have TOKEN_CREATE and TOKEN_DELETE permissions to teleport a Token to a different Scene.");
      }

      // Get destination and destination token
      const destinationToken = destinationTokens[i];
      const destination = destinations[i];

      // Get additional changes
      const changes = updateData?.get(originToken);

      // If the origin and destination scene are the same, update the token
      if ( originToken === destinationToken ) {
        toUpdate.push({...changes, _id: originToken.id});
        movement[originToken.id] = {waypoints: [{...destination, action: "displace"}]};
        continue;
      }

      // Otherwise create a new token and delete the old one
      const data = foundry.utils.mergeObject(destinationToken.toObject(), {...changes, ...destination});
      let destinationId = originToken.id;
      if ( !destinationId || destinationScene.tokens.has(destinationId) ) {
        do {
          destinationId = foundry.utils.randomID();
        } while ( takenIds.has(destinationId) );
      }
      data._id = destinationId;
      toCreate.push(data);

      const destinationUuid = foundry.utils.buildUuid({id: destinationId, documentName: "Token", parent: destinationScene});
      newUuids.set(originToken, destinationUuid);

      // Delete old token
      let replacements = toDelete.get(originScene);
      if ( !replacements ) toDelete.set(originScene, replacements = {});
      replacements[originToken.id] = destinationUuid;
    }

    const operations = [];
    if ( toUpdate.length ) {
      operations.push({
        action: "update",
        documentName: "Token",
        updates: toUpdate,
        parent: destinationScene,
        movement,
        animation: {linkToMovement: true},
        pan
      });
    }
    if ( toCreate.length ) {
      operations.push({
        action: "create",
        documentName: "Token",
        parent: destinationScene,
        data: toCreate,
        keepId: true
      });
    }
    for ( const [originScene, replacements] of toDelete.entries() ) {
      operations.push({
        action: "delete",
        documentName: "Token",
        ids: Object.keys(replacements),
        parent: originScene,
        replacements
      });
    }
    if ( operations.length ) await foundry.documents.modifyBatch(operations);

    // Return the mapping of deleted to created tokens
    const tokenMap = new Map();
    for ( const [originToken, destinationUuid] of newUuids.entries() ) {
      const destinationToken = foundry.utils.fromUuidSync(destinationUuid);
      if ( destinationToken ) tokenMap.set(originToken, destinationToken);
    }
    return tokenMap;
  }

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

  /**
   * Spawn Tokens into this Region.
   *
   * The current User must be an owner of the Token Documents and have the `TOKEN_CREATE` permission
   * in order to spawn them.
   *
   * This function can work ephemeral (non-persisted) Region documents.
   * @param {Iterable<Partial<TokenData>|TokenDocument>} tokenData       The data of tokens or Token documents to spawn.
   * @param {object} [options]                                           Additional options.
   * @param {"random"|"center"|"relative"} [options.placement="random"]  The placement. Default: `"random"`.
   * @param {boolean} [options.snap=true]      Attempt to spawn the tokens to a snapped position. Default: `true`.
   * @param {Point} [options.offset]           The relative offset position. Default: `true`.
   * @param {boolean} [options.avoidOccupied=true]  Avoid occupied grid spaces when placing randomly with snapping.
   *                                                Default: `true`.
   * @param {string} [options.level]                The destination Level ID, which must be a Level this Region is in.
   *                                                Default: the Level of the Region if it is in only one Level.
   * @param {boolean} [options.create=true]         Persist the spawned tokens to the database (default true) or
   *                                                otherwise return an array of ephemeral TokenDocument instances.
   * @param {Partial<Omit<DatabaseCreateOperation, "parent">>} [options.createOptions]  Additional create options.
   * @returns {Promise<TokenDocument[]>}       The array of Token Documents that where created, which might be
   *                                           less than request the creation was disallowed by a preCreate handler
   * @throws {Error}                           If the current User doesn't have the necessary permissions, the Token
   *                                           Document could not be created or there's no valid placement.
   * @example Spawn 10 tokens with random actor and random rotation in a placed circle with 30 grid units radius.
   * ```js
   * ui.notifications.info("Choose the placement for the spawn area.");
   * const spawnArea = await canvas.regions.placeRegion({
   *   name: "Spawn Area",
   *   shapes: [{
   *     type: "circle",
   *     x: 0,
   *     y: 0,
   *     radius: canvas.dimensions.distancePixels * 30
   *   }],
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id]
   * }, {create: false});
   * if ( spawnArea ) {
   *   const {count: numTokensToSpawn=0} = await foundry.applications.api.DialogV2.input({
   *    window: {
   *       title: "How many tokens to you want to spawn?"
   *    },
   *    content: `<input type="number" name="count" min="0" step="1" value="10">`
   *   }) ?? {};
   *   const actors = game.actors.contents;
   *   const tokensToSpawn = [];
   *   for ( let i = 0; i < numTokensToSpawn; i++ ) {
   *     const actor = actors[Math.floor(Math.random() * actors.length)];
   *     const token = await actor.getTokenDocument({
   *       rotation: Math.random() * 360
   *     }, {parent: spawnArea.parent});
   *     tokensToSpawn.push(token);
   *   }
   *   const spawnedTokens = await spawnArea.spawnTokens(tokensToSpawn);
   * }
   * ```
   */
  async spawnTokens(tokenData, {placement="random", snap=true, offset, avoidOccupied=true, level, create=true,
    createOptions}={}) {
    if ( this.inCompendium ) throw new Error("The Region document must not be in a compendium.");
    if ( !game.user.can("TOKEN_CREATE") ) throw new Error("You must have the TOKEN_CREATE permission to spawn a Token.");
    const scene = this.parent;
    const cls = TokenDocument.implementation;

    // Convert token data to token documents
    const tokens = Array.from(tokenData, data => {
      if ( data instanceof TokenDocument ) {
        if ( data.parent === scene ) return data;
        data = data.toObject();
      }
      else data = foundry.utils.deepClone(data);
      return new cls(data, {parent: scene});
    });
    if ( tokens.length === 0 ) return [];

    // Get a destination for each token
    const destinations = this.#getTokenDestinations(tokens, {placement, snap, offset, avoidOccupied, level});

    // Build creation data
    const toCreate = [];
    for ( const [index, token] of tokens.entries() ) {
      if ( !token.isOwner ) throw new Error("You must be an owner of the Tokens in order to spawn them.");
      toCreate.push(Object.assign(token.toObject(), destinations[index]));
    }

    // Return ephemeral tokens that are not persisted
    if ( !create ) return toCreate.map(data => cls.fromSource(data, {parent: scene}));

    // Create the tokens
    return scene.createEmbeddedDocuments("Token", toCreate, createOptions);
  }

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

  /**
   * Initialize the placement context.
   *
   * The tokens are grouped by dimensions. For each group we compute the number of occupied grid spaces and
   * the pivot of the shape. For each token we calculate the destination elevation. We sort the groups by
   * number of occupied grid spaces in descending order if the `avoidOccupied` option is true: this is the
   * order in which the tokens are attempted to be placed increasing the chance for the best random placement.
   * @param {TokenDocument[]} tokens           The tokens to place
   * @param {object} options                   Additional options
   * @param {"random"|"center"|"relative"} options.placement  The placement
   * @param {boolean} options.snap             Attempt to teleport the token to a snapped position
   * @param {Point} [options.offset]           The relative offset position
   * @param {boolean} [options.avoidOccupied]  Avoid occupied grid spaces when placing randomly with snapping
   * @param {string} options.level             The destination level
   * @returns {object}
   */
  #initializeTokenPlacementContext(tokens, {placement, snap, offset, avoidOccupied, level}) {
    if ( this.area === 0 ) throw new Error("Region is empty.");
    const scene = this.parent;
    if ( !level ) {
      if ( (this.levels.size >= 2) || (!this.levels.size && (scene.levels.size !== 1)) ) throw new Error("Region is in multiple levels.");
      level = this._source.levels.at(0) ?? scene.levels.contents[0].id;
    }
    else if ( !this.includedInLevel(level) ) throw new Error("Region is not in the destination level.");
    const grid = scene.grid;
    const context = {tokens: [], groups: [], placement, snap, offset, avoidOccupied, level};
    const tokenData = new Map();
    for ( const token of tokens ) {

      // Clamp the elevation of the token the elevation range of the destination region:
      // try to get the head of the token in if possible, but more importantly the feet of the token.
      const elevation = this.clampElevation(token._source.elevation, token._source.depth);
      tokenData.set(token, {index: tokenData.size, elevation});
    }

    // Group by snapped elevation/dimensions/shape
    for ( const groupTokens of Object.values(Object.groupBy(tokens, token => {
      let {width, height, depth, shape} = token._source;
      if ( grid.isGridless ) {
        if ( (shape === TOKEN_SHAPES.ELLIPSE_1) || (shape === TOKEN_SHAPES.ELLIPSE_2) ) shape = TOKEN_SHAPES.ELLIPSE_1;
        else shape = TOKEN_SHAPES.RECTANGLE_1;
      }
      if ( grid.isSquare ) shape = TOKEN_SHAPES.RECTANGLE_1;
      const {elevation} = tokenData.get(token);
      const snappedElevation = token.getSnappedPosition({x: 0, y: 0, elevation, width, height, depth, shape}).elevation;
      return `${snappedElevation},${width},${height},${depth},${shape}`;
    })) ) {
      const representative = groupTokens[0];

      // Compute the pivot for later use
      const {width, height, depth, shape} = representative._source;
      const pivot = representative.getCenterPoint({x: 0, y: 0, elevation: 0, width, height, shape});

      // Calculate the priority (only relevant to sort by size in descending order)
      let priority;
      if ( avoidOccupied ) {
        priority = Math.ceil(Math.max(Math.round(width * 2) / 2, 0.5))
          * Math.ceil(Math.max(Math.round(height * 2) / 2, 0.5))
          * Math.ceil(Math.max(Math.round(depth * 2) / 2, 0.5));
      }

      const group = {tokens: [], pivot, width, height, depth, shape, priority};
      context.groups.push(group);

      // Populate tokens of this group
      for ( const token of groupTokens ) {
        group.tokens.push({...tokenData.get(token), document: token, group, placements: []});
      }
    }

    // Sort by size in descending order to improve probability of a placement with the lowest score
    if ( avoidOccupied ) context.groups.sort((a, b) => b.priority - a.priority);

    // Populate the tokens
    for ( const group of context.groups ) {
      for ( const token of group.tokens ) context.tokens.push(token);
    }

    return context;
  }

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

  /**
   * Compute the areas of the triangulation.
   *
   * Used to calculate the center (centroid) placement and for random unsnapped placement.
   * @param {object} context    The placement context
   * @returns {{triangles: number[]; total: number}}
   */
  #computeTokenPlacementArea(context) {
    if ( context.area ) return context.area;
    const triangleAreas = [];
    let totalArea = 0;
    const {vertices, indices} = this.triangulation;
    for ( let k = 0; k < indices.length; 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];
      const area = Math.abs(((x1 - x0) * (y2 - y0)) - ((x2 - x0) * (y1 - y0))) / 2;
      triangleAreas.push(area);
      totalArea += area;
    }
    return context.area = {triangles: triangleAreas, total: totalArea};
  }

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

  /**
   * Compute the center (centroid) placement.
   * @param {object} context    The placement context
   * @returns {Point}
   */
  #computeCenterTokenPlacement(context) {
    if ( context.center ) return context.center;
    const {triangles: triangleAreas, total: totalArea} = this.#computeTokenPlacementArea(context);
    const center = {x: 0.0, y: 0.0};
    const {vertices, indices} = this.triangulation;
    for ( let k = 0; k < indices.length; 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];
      const cx = (x0 + x1 + x2) / 3;
      const cy = (y0 + y1 + y2) / 3;
      const area = triangleAreas[k / 3];
      center.x += (area * cx);
      center.y += (area * cy);
    }
    center.x /= totalArea;
    center.y /= totalArea;
    return context.center = center;
  }

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

  /**
   * Compute the relative placement given the offset.
   * @param {object} context    The placement context
   * @returns {Point}
   */
  #computeRelativeTokenPlacement(context) {
    if ( context.relative ) return context.relative;
    if ( !context.offset ) throw new Error("Relative placement requires an offset");
    return context.relative = {
      x: Math.mix(this.bounds.left, this.bounds.right, context.offset.x),
      y: Math.mix(this.bounds.top, this.bounds.bottom, context.offset.y)
    };
  }

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

  /**
   * Compute all possible snapped placements.
   * @param {object} context    The placement context
   */
  #computeSnappedTokenPlacements(context) {
    const scene = this.parent;
    const grid = scene.grid;
    if ( grid.isGridless ) return;
    if ( context.containedSpaces ) return;
    const {level} = context;

    // Iterate over possibly contained grid space offsets
    const containedSpaces = context.containedSpaces = new Map();
    const [i0, j0, i1, j1] = grid.getOffsetRange(new PIXI.Rectangle(
      0, 0, scene.dimensions.width, scene.dimensions.height).fit(this.bounds).pad(1));
    for ( let i = i0; i < i1; i++ ) {
      for ( let j = j0; j < j1; j++ ) {
        const offset = {i, j};

        // Drop the token with its center point on the grid space center and snap the token position
        const center = grid.getCenterPoint(offset);

        // The grid space center must be inside the region to be a valid drop target
        if ( !this.polygonTree.testPoint(center, 0.75) ) continue;

        // Record contained offset
        containedSpaces.set(`${i},${j}`, {offset, center});
      }
    }

    // Iterate of each group and calculate the possible placements for each token in the group
    for ( const group of context.groups ) {

      // The snapped position, center point, and occupied offset are the same for each token in the group
      const {tokens, pivot, width, height, depth, shape} = group;
      const {document: representative} = tokens[0];
      const data = {x: 0, y: 0, elevation: representative._source.elevation, width, height, depth, shape, level};

      // Compute candidate placements
      const candidates = [];
      for ( const {center} of containedSpaces.values() ) {
        data.x = center.x - pivot.x;
        data.y = center.y - pivot.y;
        let {x, y} = representative.getSnappedPosition(data);
        data.x = x = Math.round(x);
        data.y = y = Math.round(y);

        // Compute the occupied offsets of the token at this position
        const occupiedOffsets = representative.getOccupiedGridSpaceOffsets(data);

        // Calculate how many occupied grid spaces are outside or would-be occupied if it wasn't for a wall/surface
        let inside = 0;
        for ( const {i, j, k} of occupiedOffsets ) {
          if ( ((k + 1) * grid.distance > this.elevation.bottom) && (k * grid.distance <= this.elevation.top)
            && containedSpaces.has(`${i},${j}`) ) inside++;
        }
        const outside = representative.getMaxOccupiedGridSpaceCount(data) - inside;

        candidates.push({x, y, occupiedOffsets, outside});
      }

      // Sort candidates by number of outside occupied spaces in ascending order
      candidates.sort((a, b) => a.outside - b.outside);

      // Iterate over all candidates and potentially add them as a possible placement for the tokens
      for ( const {x, y, occupiedOffsets, outside} of candidates ) {
        data.x = x;
        data.y = y;

        // Get the offset keys of the grid spaces that are occupied by the token at this position
        const occupied = new Set();
        for ( const {i, j, k} of occupiedOffsets ) occupied.add(`${i},${j},${k}`);

        // Add possible placements for each token at this position
        for ( const {document, elevation, placements} of tokens ) {
          data.elevation = elevation;

          // The token itself must be inside the region
          if ( !document.testInsideRegion(this, data) ) continue;

          // The token's movement origin must be inside the region
          if ( !this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) continue;

          // Record the possible placement for the token
          placements.push({position: {x, y, elevation, level}, occupied, outside});
        }
      }
    }
  }

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

  /**
   * Compute the placement bounds.
   *
   * Any other token in the scene out these bounds can be ignored for calculating the placement score.
   * @param {TokenDocument[]} tokens
   * @returns {PIXI.Rectangle}
   */
  #computeTokenPlacementBounds(tokens) {
    const scene = this.parent;
    let paddingLeft = 0;
    let paddingTop = 0;
    let maxWidth = 0;
    let maxHeight = 0;
    for ( const token of tokens ) {
      const {x, y} = token._source;
      const center = token.getCenterPoint(token._source);
      paddingLeft = Math.max(paddingLeft, center.x - x);
      paddingTop = Math.max(paddingTop, center.y - y);
      const {width, height} = token.getSize(token._source);
      maxWidth = Math.max(maxWidth, width);
      maxHeight = Math.max(maxHeight, height);
    }
    const placementBounds = new PIXI.Rectangle(
      0, 0, scene.dimensions.width, scene.dimensions.height).fit(this.bounds);
    placementBounds.x -= paddingLeft;
    placementBounds.y -= paddingTop;
    placementBounds.width += maxWidth;
    placementBounds.height += maxHeight;
    return placementBounds;
  }

  /**
   * Compute initially occupied offsets.
   * @param {TokenDocument[]} tokens
   * @returns {Record<string, number[]>}
   */
  #computeInitiallyOccupiedOffsetsByTokens(tokens) {
    const initiallyOccupied = {};
    const scene = this.parent;
    const placementBounds = this.#computeTokenPlacementBounds(tokens);
    const tokensToPlace = new Set(tokens);
    const tokenBounds = new PIXI.Rectangle();
    const {bottom, top, topInclusive} = this.elevation;
    let index = 0;
    for ( const token of scene.tokens ) {

      // Ignore tokens that are about to be placed
      if ( tokensToPlace.has(token) ) continue;

      // Ignore tokens outside of the elevation range
      const {elevation: feet, depth} = token._source;
      if ( topInclusive ) {
        if ( feet > top ) continue;
      } else {
        if ( (feet !== bottom) && (feet >= top) ) continue;
      }
      const head = feet + (scene.grid.distance * depth);
      if ( depth === 0 ) {
        if ( head < bottom ) continue;
      } else {
        if ( head <= bottom ) continue;
      }

      // Ignore tokens that could never overlap with placed tokens
      const {x, y} = token._source;
      tokenBounds.x = x;
      tokenBounds.y = y;
      const {width, height} = token.getSize(token._source);
      tokenBounds.width = width;
      tokenBounds.height = height;
      if ( !placementBounds.intersects(tokenBounds) ) continue;

      // Record occupied offsets
      const tokenOffsets = token.getOccupiedGridSpaceOffsets(token._source);
      for ( const {i, j, k} of tokenOffsets ) {
        const indices = initiallyOccupied[`${i},${j},${k}`] ??= [];
        indices.push(index);
      }

      index++;
    }

    return initiallyOccupied;
  }

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

  /**
   * Get destinations for the Tokens within this Region that places the Token and its center point inside it.
   * @param {TokenDocument[]} tokens           The tokens that are placed
   * @param {object} options                   Additional options
   * @param {"random"|"center"|"relative"} options.placement The placement
   * @param {boolean} options.snap             Attempt to teleport the token to a snapped position
   * @param {Point} [options.offset]           The relative offset position
   * @param {boolean} [options.avoidOccupied]  Avoid occupied grid spaces when placing randomly with snapping
   * @param {string} [options.level]           The destination level
   * @returns {TokenCoordinates[]}  The destinations
   * @throws {Error}                           If unable to find a placement for any token
   */
  #getTokenDestinations(tokens, options) {
    if ( tokens.length === 0 ) return [];
    const context = this.#initializeTokenPlacementContext(tokens, options);

    // If not random placement with snapping that avoids occupied grid spaces
    const {placement, snap, avoidOccupied} = context;
    if ( (placement !== "random") || !snap || !avoidOccupied || this.parent.grid.isGridless ) {
      const destinations = new Array(tokens.length);
      for ( const token of context.tokens ) destinations[token.index] = this.#getTokenDestination(context, token);
      return destinations;
    }

    // Otherwise try to find the best random placement with the least overlap
    const initiallyOccupied = this.#computeInitiallyOccupiedOffsetsByTokens(tokens);
    let bestScore = Infinity;
    let bestDestinations;
    let attempts = 100;
    while ( attempts ) {
      const occupiedOffsets = context.occupiedOffsets = {};
      for ( const [key, indices] of Object.entries(initiallyOccupied) ) occupiedOffsets[key] = indices.slice();

      // Calculate the score as the sum of the number grid space overlaps and
      // the number of occupied grid spaces outside of the spawn area
      let score = 0;

      // Randomize the position of all tokens and count occupied grid spaces
      const destinations = new Array(tokens.length);
      for ( const [index, token] of context.tokens.entries() ) {
        const destination = this.#getBestRandomSnappedTokenDestination(context, token);
        destinations[token.index] = destination.position;
        for ( const key of destination.occupied ) {
          const indices = occupiedOffsets[key] ??= [];
          indices.push(~index); // Negative indices for tokens that are to be placed
        }
        score += destination.score;
      }

      // If this positioning is better than the previously best one, update the best score and positions
      if ( bestScore > score ) {
        bestScore = score;
        bestDestinations = destinations;

        // The perfect score is zero
        if ( bestScore === 0 ) break;
      }

      attempts--;
    }

    return bestDestinations;
  }

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

  /**
   * Get a destination for the Token within this Region that places the Token and its center point inside it.
   * @param {object} context                                      The placement context
   * @param {object} token                                        The token context
   * @returns {TokenCoordinates}  The destination
   */
  #getTokenDestination(context, token) {
    switch ( context.placement ) {
      case "center": return this.#getCenterTokenDestination(context, token);
      case "relative": return this.#getRelativeTokenDestination(context, token);
      case "random": return this.#getRandomTokenDestination(context, token);
      default: throw new Error("Invalid placement method.");
    }
  }

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

  /**
   * Try to place the token at the center of the region.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getCenterTokenDestination(context, token) {
    const center = this.#computeCenterTokenPlacement(context);
    return this.#tryPlaceTokenAt(context, token, center);
  }

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

  /**
   * Try to place the token at the relative position based of the provided offset.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRelativeTokenDestination(context, token) {
    const relative = this.#computeRelativeTokenPlacement(context);
    return this.#tryPlaceTokenAt(context, token, relative);
  }

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

  /**
   * Pick a destination randomly if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRandomTokenDestination(context, token) {
    if ( context.snap && !this.parent.grid.isGridless ) return this.#getRandomSnappedTokenDestination(context, token);
    return this.#getRandomUnsnappedTokenDestination(context, token);
  }

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

  /**
   * Pick a unsnapped destination randomly if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRandomUnsnappedTokenDestination(context, token) {
    const {document, group: {pivot, width, height, depth, shape}, elevation} = token;
    const {vertices, indices} = this.triangulation;
    const {level} = context;
    const {triangles: triangleAreas, total: totalArea} = this.#computeTokenPlacementArea(context);

    // Try to find a position that places the token inside the region
    for ( let n = 0; n < 10; n++ ) {

      // Choose a triangle randomly weighted by area
      let j;
      let a = totalArea * Math.random();
      for ( j = 0; j < triangleAreas.length - 1; j++ ) {
        a -= triangleAreas[j];
        if ( a < 0 ) break;
      }
      const k = 3 * j;
      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];

      // Select a random point within the triangle
      const r1 = Math.sqrt(Math.random());
      const r2 = Math.random();
      const s = r1 * (1 - r2);
      const t = r1 * r2;
      const x = Math.round(x0 + ((x1 - x0) * s) + ((x2 - x0) * t) - pivot.x);
      const y = Math.round(y0 + ((y1 - y0) * s) + ((y2 - y0) * t) - pivot.y);
      const data = {x, y, elevation, width, height, depth, shape, level};

      // The token itself must be inside the region
      if ( !document.testInsideRegion(this, data) ) continue;

      // The token's movement origin must be inside the region
      if ( !this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) continue;

      return {x, y, elevation, level};
    }

    throw new Error("Failed to find placement.");
  }

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

  /**
   * Pick a snapped destination randomly if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {TokenCoordinates}
   */
  #getRandomSnappedTokenDestination(context, token) {
    this.#computeSnappedTokenPlacements(context);

    // Pick randomly from the placements
    const {placements} = token;
    if ( placements.length ) {

      // Choose a position with the least grid spaces outside of the region
      const minOutside = placements[0].outside;
      let k;
      if ( placements.at(-1).outside === minOutside ) k = placements.length;
      else {
        k = 1;
        while ( placements.at(k)?.outside === minOutside ) k++;
      }
      return placements[Math.floor(k * Math.random())].position;
    }

    // If there are no snapped placements, fallback to an unsnapped one
    return this.#getRandomUnsnappedTokenDestination(context, token);
  }

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

  /**
   * Pick a snapped destination randomly with the lowest score if possible.
   * @param {object} context    The placement context
   * @param {object} token      The token context
   * @returns {{position: TokenCoordinates; occupied: Set<string>; score: number}}
   */
  #getBestRandomSnappedTokenDestination(context, token) {
    this.#computeSnappedTokenPlacements(context);

    // If there are no snapped placements, fallback to an unsnapped one
    let {placements} = token;
    if ( !placements.length ) {
      const grid = this.parent.grid;
      const position = this.#getRandomUnsnappedTokenDestination(context, token);
      const {document, group: {width, height, depth, shape}} = token;
      const data = {...position, width, height, depth, shape};
      const occupiedOffsets = document.getOccupiedGridSpaceOffsets(data);
      const occupied = new Set();
      let inside = 0;
      for ( const {i, j, k} of occupiedOffsets ) {
        occupied.add(`${i},${j},${k}`);
        if ( ((k + 1) * grid.distance > this.elevation.bottom) && (k * grid.distance <= this.elevation.top)
          && context.containedSpaces.has(`${i},${j}`) ) inside++;
      }
      const outside = document.getMaxOccupiedGridSpaceCount(data) - inside;
      placements = [{position, occupied, outside}];
    }

    // Find the placements with the lowest score
    const candidates = [];
    let minScore = Infinity;
    const {occupiedOffsets} = context;
    for ( const placement of placements ) {
      const overlaps = {};
      for ( const key of placement.occupied ) {
        const indices = occupiedOffsets[key];
        if ( !indices ) continue;
        for ( const index of indices ) overlaps[index] = (overlaps[index] ?? 0) + 1;
      }
      let score = placement.outside * placement.outside; // Outside score
      for ( const count of Object.values(overlaps) ) score += (count * count); // Overlapping score
      if ( minScore > score ) {
        minScore = score;
        candidates.length = 0;
      }
      if ( score === minScore ) candidates.push(placement);
    }

    // Pick randomly from the placements with the lowest score
    const {position, occupied} = candidates[Math.floor(candidates.length * Math.random())];
    return {position, occupied, score: minScore};
  }

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

  /**
   * Try to place the token centered on the given point if possible
   * @param {object} context                                      The placement context
   * @param {object} token                                        The token context
   * @param {ElevatedPoint} point                                 The point to place the token at
   * @returns {TokenCoordinates}  The position of the token placement
   */
  #tryPlaceTokenAt(context, token, point) {
    if ( this.polygonTree.testPoint(point, 0.75) ) {
      const {level, snap} = context;
      const {document, group: {pivot, width, height, depth, shape}, elevation} = token;

      // Place the token at on the center
      let position = {x: point.x - pivot.x, y: point.y - pivot.y, elevation: 0};
      if ( snap ) position = document.getSnappedPosition({...position, width, height, shape});
      const x = Math.round(position.x);
      const y = Math.round(position.y);
      const data = {x, y, elevation, width, height, depth, shape, level};

      // Check if the token and its movement origin is inside the region in the snapped position
      if ( document.testInsideRegion(this, data)
        && this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) {
        return {x, y, elevation, level};
      }

      // If the snapped position isn't inside the region, try the unsnapped position
      if ( snap ) {
        const x = Math.round(point.x - pivot.x);
        const y = Math.round(point.y - pivot.y);
        const data = {x, y, elevation, width, height, depth, shape, level};
        if ( document.testInsideRegion(this, data)
          && this.polygonTree.testPoint(document.getMovementOrigin(data), -0.75) ) {
          return {x, y, elevation, level};
        }
      }
    }

    // Otherwise pick any snapped position
    return this.#getRandomSnappedTokenDestination(context, token);
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Activate the Socket event listeners.
   * @param {io.Socket} socket    The active game socket
   * @internal
   */
  static _activateSocketListeners(socket) {
    socket.on("regionEvent", this.#onSocketEvent.bind(this));
  }

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

  /**
   * Handle the Region event received via the socket.
   * @param {RegionSocketEvent} socketEvent    The socket Region event
   */
  static async #onSocketEvent(socketEvent) {
    const {regionUuid, userId, eventName, eventData, eventDataUuids} = socketEvent;
    if ( userId === game.user.id ) return; // Already handled when the event was triggered
    const region = await fromUuid(regionUuid);
    if ( !region ) return;
    for ( const key of eventDataUuids ) {
      const uuid = foundry.utils.getProperty(eventData, key);
      const document = await fromUuid(uuid);
      foundry.utils.setProperty(eventData, key, document);
    }
    const event = {name: eventName, data: eventData, region, user: game.users.get(userId)};
    await region._handleEvent(event);
  }

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

  /**
   * Update the tokens of the given regions.
   * @param {RegionDocument[]} regions           The Regions documents, which must be all in the same Scene
   * @param {object} [options={}]                Additional options
   * @param {{[tokenId: string]: string[]}} [options.attachments] The IDs of regions that are attached to
   *  the tokens. If this is set, the region were updated as as part of a token movement.
   * @param {boolean} [options.deleted=false]    Are the Region documents deleted?
   */
  static async #updateTokens(regions, {attachments, deleted=false}={}) {
    if ( regions.length === 0 ) return;
    const updates = [];
    const scene = regions[0].parent;
    if ( deleted ) {
      const deletedRegionIds = new Set();
      const affectedTokens = new Set();
      for ( const region of regions ) {
        deletedRegionIds.add(region.id);
        for ( const token of region.tokens ) affectedTokens.add(token);
      }
      for ( const token of affectedTokens ) {
        const updatedRegionIds = token._source._regions.filter(id => !deletedRegionIds.has(id));
        if ( updatedRegionIds.length === token._source._regions.length ) continue;
        updates.push({_id: token.id, _regions: updatedRegionIds});
      }
    } else {
      for ( const token of scene.tokens ) {
        const regionIds = new Set(token._source._regions);
        const ignoredRegionIds = new Set(attachments?.[token.id]);
        let regionsChanged = false;
        for ( const region of regions ) {
          if ( ignoredRegionIds.has(region.id) ) continue;
          const inside = token.testInsideRegion(region);
          if ( inside === regionIds.has(region.id) ) continue;
          if ( inside ) regionIds.add(region.id);
          else regionIds.delete(region.id);
          regionsChanged = true;
        }
        if ( !regionsChanged ) continue;
        updates.push({_id: token.id, _regions: Array.from(regionIds).sort()});
      }
    }
    if ( updates.length === 0 ) return;
    await scene.updateEmbeddedDocuments("Token", updates, {diff: false, noHook: true});
  }

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

  /** @inheritDoc */
  static async _onCreateOperation(documents, operation, user) {
    if ( game.user.isActiveGM ) {
      RegionDocument.#updateTokens(documents);
    }
    for ( const region of documents ) {
      region.attachment.token?.attachments.regions.add(region);
      const status = {active: true};
      region._handleEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region, user});
      if ( region.viewed ) {
        status.viewed = true;
        region._handleEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region, user});
      }
      /** @deprecated since v13 */
      region._handleEvent({name: "behaviorStatus", data: status, region, user});
    }

    return super._onCreateOperation(documents, operation, user);
  }

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

  /** @inheritDoc */
  static async _onUpdateOperation(documents, operation, user) {
    await super._onUpdateOperation(documents, operation, user);

    const regionsHiddenChanged = new Set();
    const regionsBoundaryChanged = [];
    for ( let i = 0; i < documents.length; i++ ) {
      const document = documents[i];
      const changed = operation.updates[i];
      if ( "hidden" in changed ) regionsHiddenChanged.add(document);
      if ( ("shapes" in changed) || ("elevation" in changed) || ("levels" in changed) || ("_shapeConstraints" in changed)
         || (("restriction" in changed) && (("enabled" in changed.restriction) || (document.restriction.enabled
          && (("type" in changed.restriction) || ("priority" in changed.restriction))))) ) {
        regionsBoundaryChanged.push(document);
      }
    }

    // Update tokens
    if ( game.user.isActiveGM ) {
      RegionDocument.#updateTokens(regionsBoundaryChanged, {attachments: operation._attachedRegions});
    }

    // Trigger status and enter/exit events
    for ( const region of regionsHiddenChanged ) {

      // Region became hidden
      if ( region.hidden ) {
        for ( const behavior of region.behaviors ) {
          if ( behavior.disabled ) continue;

          // Trigger exit events
          for ( const token of region.tokens ) {
            const deleted = !region.parent.tokens.has(token.id);
            if ( deleted ) continue;
            behavior._handleRegionEvent({
              name: REGION_EVENTS.TOKEN_EXIT,
              data: {token, movement: null},
              region,
              user
            });
          }

          // Trigger status events
          const status = {active: false};
          if ( region.viewed ) {
            status.viewed = false;
            behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region, user});
          }
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region, user});
          /** @deprecated since v13 */
          behavior._handleRegionEvent({name: "behaviorStatus", data: status, region, user});
        }
      }

      // Region became unhidden
      else {

        // Trigger status events
        const status = {active: true};
        region._handleEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region, user});
        if ( region.viewed ) {
          status.viewed = true;
          region._handleEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region, user});
        }
        /** @deprecated since v13 */
        region._handleEvent({name: "behaviorStatus", data: status, region, user});

        // Trigger enter events
        for ( const token of region.tokens ) {
          const deleted = !region.parent.tokens.has(token.id);
          if ( deleted ) continue;
          region._handleEvent({
            name: REGION_EVENTS.TOKEN_ENTER,
            data: {token, movement: null},
            region,
            user
          });
        }
      }

    }

    // Trigger boundary events
    for ( const region of regionsBoundaryChanged ) {

      // Don't trigger boundary events for regions that become (un)hidden
      if ( regionsHiddenChanged.has(region) ) continue;

      region._handleEvent({
        name: REGION_EVENTS.REGION_BOUNDARY,
        data: {},
        region,
        user
      });
    }
  }

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

  /** @inheritDoc */
  static async _onDeleteOperation(documents, operation, user) {
    if ( game.user.isActiveGM ) {
      RegionDocument.#updateTokens(documents, {deleted: true});
    }
    const regionEvents = [];
    for ( const region of documents ) {
      for ( const token of region.tokens ) {
        region.tokens.delete(token);
        regionEvents.push({
          name: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement: null},
          region,
          user
        });
      }
      region.tokens.clear();
    }
    for ( const region of documents ) {
      const status = {active: false};
      if ( region.viewed ) {
        status.viewed = false;
        regionEvents.push({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region, user});
      }
      regionEvents.push({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region, user});
      /** @deprecated since v13 */
      regionEvents.push({name: "behaviorStatus", data: status, region, user});
    }
    for ( const event of regionEvents ) {
      event.region._handleEvent(event);
    }
    return super._onDeleteOperation(documents, operation, user);
  }

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

  /**
   * Trigger the Region event.
   * @param {string} eventName        The event name
   * @param {object} eventData        The event data
   * @returns {Promise<void>}
   * @internal
   */
  async _triggerEvent(eventName, eventData) {
    const event = {name: eventName, data: eventData, region: this, user: game.user};

    // Serialize Documents in the event data as UUIDs
    eventData = foundry.utils.deepClone(eventData);
    const eventDataUuids = [];
    const serializeDocuments = (object, key, path=key) => {
      const value = object[key];
      if ( (value === null) || (typeof value !== "object") ) return;
      if ( !value.constructor || (value.constructor === Object) ) {
        for ( const key in value ) serializeDocuments(value, key, `${path}.${key}`);
      } else if ( Array.isArray(value) ) {
        for ( let i = 0; i < value.length; i++ ) serializeDocuments(value, i, `${path}.${i}`);
      } else if ( value instanceof foundry.abstract.Document ) {
        object[key] = value.uuid;
        eventDataUuids.push(path);
      }
    };
    for ( const key in eventData ) serializeDocuments(eventData, key);

    // Emit socket event
    game.socket.emit("regionEvent", {
      regionUuid: this.uuid,
      userId: game.user.id,
      eventName,
      eventData,
      eventDataUuids
    });

    // Handle event for the current user immediately
    await this._handleEvent(event);
  }

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

  /**
   * Handle the Region event.
   * @param {RegionEvent} event    The Region event
   * @returns {Promise<void>}
   * @internal
   */
  async _handleEvent(event) {
    if ( this.hidden ) return;
    const results = await Promise.allSettled(this.behaviors.filter(b => !b.disabled)
      .map(b => b._handleRegionEvent(event)));
    for ( const result of results ) {
      if ( result.status === "rejected" ) console.error(result.reason);
    }
  }

  /* -------------------------------------------- */
  /*  Database Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    if ( (collection !== "behaviors") || this.hidden ) return;

    // Trigger events
    const user = game.users.get(userId);
    for ( let i = 0; i < documents.length; i++ ) {
      const behavior = documents[i];
      if ( behavior.disabled ) continue;

      // Trigger status event
      behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region: this, user});
      const status = {active: true};
      if ( this.viewed ) {
        status.viewed = true;
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region: this, user});
      }
      /** @deprecated since v13 */
      behavior._handleRegionEvent({name: "behaviorStatus", data: status, region: this, user});

      // Trigger enter events
      for ( const token of this.tokens ) {
        const deleted = !this.parent.tokens.has(token.id);
        if ( deleted ) continue;
        behavior._handleRegionEvent({
          name: REGION_EVENTS.TOKEN_ENTER,
          data: {token, movement: null},
          region: this,
          user
        });
      }
    }
  }

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

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( (collection !== "behaviors") || this.hidden ) return;

    // Trigger events
    const user = game.users.get(userId);
    for ( let i = 0; i < documents.length; i++ ) {
      const disabled = changes[i].disabled;
      if ( disabled === undefined ) continue;
      const behavior = documents[i];

      // Trigger exit events
      if ( disabled ) {
        for ( const token of this.tokens ) {
          const deleted = !this.parent.tokens.has(token.id);
          if ( deleted ) continue;
          behavior._handleRegionEvent({
            name: REGION_EVENTS.TOKEN_EXIT,
            data: {token, movement: null},
            region: this,
            user
          });
        }
      }

      // Trigger status event
      if ( disabled ) {
        if ( this.viewed ) {
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region: this, user});
        }
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region: this, user});
      } else {
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_ACTIVATED, data: {}, region: this, user});
        if ( this.viewed ) {
          behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_VIEWED, data: {}, region: this, user});
        }
      }
      const status = {active: !disabled};
      if ( this.viewed ) status.viewed = !disabled;
      /** @deprecated since v13 */
      behavior._handleRegionEvent({name: "behaviorStatus", data: status, region: this, user});

      // Trigger enter events
      if ( !disabled ) {
        for ( const token of this.tokens ) {
          const deleted = !this.parent.tokens.has(token.id);
          if ( deleted ) continue;
          behavior._handleRegionEvent({
            name: REGION_EVENTS.TOKEN_ENTER,
            data: {token, movement: null},
            region: this,
            user
          });
        }
      }
    }
  }

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

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, ids, options, userId);
    if ( (collection !== "behaviors") || this.hidden ) return;

    // Trigger events
    const user = game.users.get(userId);
    for ( let i = 0; i < documents.length; i++ ) {
      const behavior = documents[i];
      if ( behavior.disabled ) continue;

      // Trigger exit events
      for ( const token of this.tokens ) {
        const deleted = !this.parent.tokens.has(token.id);
        if ( deleted ) continue;
        behavior._handleRegionEvent({
          name: REGION_EVENTS.TOKEN_EXIT,
          data: {token, movement: null},
          region: this,
          user
        });
      }

      // Trigger status event
      const status = {active: false};
      if ( this.viewed ) {
        status.viewed = false;
        behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_UNVIEWED, data: {}, region: this, user});
      }
      behavior._handleRegionEvent({name: REGION_EVENTS.BEHAVIOR_DEACTIVATED, data: {}, region: this, user});
      /** @deprecated since v13 */
      behavior._handleRegionEvent({name: "behaviorStatus", data: status, region: this, user});
    }
  }

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

  /**
   * Present a Dialog form to confirm the removal of a shape.
   * @param {BaseShapeData|number} shapeOrIndex  The shape or shape index.
   * @param {object} [options]                   Additional options passed to `DialogV2.confirm`
   * @returns {Promise<boolean>}
   */
  removeShapeDialog(shapeOrIndex, options={}) {
    let index;
    if ( !(shapeOrIndex instanceof BaseShapeData ) ) index = shapeOrIndex;
    else {
      const shape = shapeOrIndex;
      if ( shape.parent !== this ) throw new Error("The shape does not belong to this Region.");
      index = shape._index;
    }
    let shapes = this.shapes;
    if ( (index < 0) || (index >= shapes.length) ) throw new Error("The shape index is out of bounds.");
    const shape = shapes[index];
    const title = `${_loc("SHAPE.ACTIONS.remove")}: ${_loc(`SHAPE.TYPES.${shape.type}.name`)}`;
    return foundry.applications.api.DialogV2.confirm(foundry.utils.mergeObject({
      window: {title},
      content: `<p>${_loc("COMMON.AreYouSure")}</p>`,
      yes: {
        callback: () => {
          // Test that there haven't been any changes to the shapes since the dialog was opened
          if ( this.shapes !== shapes ) return false;
          shapes = [...shapes];
          shapes.splice(index, 1);
          this.update({shapes});
          return true;
        }
      }
    }, options));
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14
   * @ignore
   */
  get regionShapes() {
    foundry.utils.logCompatibilityWarning("RegionDocument#regionShapes is deprecated. Use RegionDocument#shapes instead.",
      {since: 14, until: 16, once: true});
    return this._regionShapes;
  }

  /** @ignore */
  get _regionShapes() {
    return this.#regionShapes ??= this.shapes.map(shape => foundry.data.regionShapes.RegionShape._create(shape));
  }

  #regionShapes;
}
