import PlaceablesLayer from "./base/placeables-layer.mjs";
import SceneControls from "../../applications/ui/scene-controls.mjs";
import VisionMaskFilter from "../rendering/filters/vision-mask-filter.mjs";
import ShapeLayerMixin from "./mixins/shapes.mjs";
import RegionDocument from "@client/documents/region.mjs";
import RegionPalette from "../../applications/sheets/palette/region-palette.mjs";
import MouseInteractionManager from "../interaction/mouse-handler.mjs";

/**
 * @import Region from "../placeables/region.mjs";
 * @import {RegionData} from "@common/documents/_types.mjs";
 * @import {Point} from "@common/_types.mjs";
 * @import { DatabaseCreateOperation } from "@common/abstract/_types.mjs";
 * @import TokenLayer from "./tokens.mjs";
 * @import {RegionPlacementOptions} from "./_types.mjs";
 */

/**
 * The Regions Container.
 * @category Canvas
 */
export default class RegionLayer extends ShapeLayerMixin(PlaceablesLayer) {

  /** @inheritDoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "regions",
      controllableObjects: true,
      confirmDeleteKey: true,
      quadtree: false,
      zIndex: 100,
      zIndexActive: 600
    });
  }

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

  /** @inheritDoc */
  static documentName = "Region";

  /** @inheritdoc */
  static paletteClass = RegionPalette;

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

  /**
   * The method to sort the Regions.
   * @type {Function}
   */
  static #sortRegions = function() {
    for ( let i = 0; i < this.children.length; i++ ) {
      this.children[i]._lastSortedIndex = i;
    }
    this.children.sort((a, b) => (a.zIndex - b.zIndex)
      || (a.document.top - b.document.top)
      || (a.document.bottom - b.document.bottom)
      || (a._lastSortedIndex - b._lastSortedIndex));
    this.sortDirty = false;
  };

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

  /** @inheritDoc */
  get hookName() {
    return RegionLayer.name;
  }

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

  /**
   * The graphics used to draw the highlighted shape.
   * @type {PIXI.Graphics}
   */
  #highlight;

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

  /**
   * The highlight meshes of the Regions.
   * @type {PIXI.Container}
   * @internal
   */
  _highlights = this.#initializeHighlights();

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

  /**
   * The shape clipboard.
   * @type {{shape: BaseShapeData|null; cut: boolean}}
   * @internal
   */
  _shapeClipboard = {shape: null, cut: false};

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

  /**
   * The placement context.
   * @type {{
   *   data: RegionData;
   *   layer: RegionLayer|TokenLayer;
   *   regionIndex: number;
   *   regionCount: number;
   *   preview: Region;
   *   shapes: BaseShapeData[];
   *   shape: BaseShapeData;
   *   create: boolean;
   *   createOptions: Partial<Omit<DatabaseCreateOperation, "parent">>;
   *   destroyPreview: boolean;
   *   allowRotation: boolean;
   *   allowEmpty: boolean;
   *   attachToToken: boolean;
   *   onMove: (args: {event: PIXI.FederatedEvent; preview: Region;  document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number; position: Point; snap: boolean}) => boolean|void;
   *   onRotate: (args: {event: WheelEvent; preview: Region; document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number; precise: boolean}) => boolean|void;
   *   onChange: (args: {preview: Region; document: RegionDocument; shape: BaseShapeData; index: number;
   *     count: number}) => void;
   *   preConfirm: (args: {event: PIXI.FederatedEvent; document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number}) => boolean|void;
   *   preSkip: (args: {event: PIXI.FederatedEvent; document: RegionDocument; shape: BaseShapeData;
   *     index: number; count: number}) => boolean|void;
   *   preCommit: (documents: ReadonlyArray<RegionDocument>) => Promise<any|void>|void;
   *   resolve: (document: RegionDocument|null) => void;
   *   reject: (error: Error) => void;
   *   rotationNotification: boolean;
   * }|null}
   * @internal
   */
  _placementContext = null;

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

  /**
   * Handle pointermove events that move the placement.
   * @type {(event: PIXI.FederatedEvent) => void}
   */
  #placementPointerMoveListener = event => {
    canvas._onDragCanvasPan(event);
    this.#movePlacement(event);
  };

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

  /**
   * Handle pointerdown events that confirm or reset the placement.
   * @type {(event: PIXI.FederatedEvent) => void}
   */
  #placementPointerDownListener = event => {
    if ( event.button === 0 ) this.#confirmPlacement(event);
    else this.#skipPlacement(event);
  };

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

  /**
   * Is Measured Template Mode enabled?
   * @type {boolean}
   */
  get templateMode() {
    return this.#templateMode;
  }

  set templateMode(value) {
    if ( this.#templateMode === value ) return;
    this.#templateMode = value;
    this.setAllRenderFlags({refreshState: true});
    if ( this.#templateMode && this.active && ui.controls.tool?.creation ) this.releaseAll();
    this._togglePaletteVisible = !this.#templateMode && !(ui.controls.tool?.creation && this.controlled.length);
    ui.controls.render({reset: true});
  }

  #templateMode = !game.user.isGM;

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

  /**
   * Is the palette toggle visible?
   * @type {boolean}
   * @internal
   */
  _togglePaletteVisible = !this.#templateMode;

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

  /**
   * Initialize the highlights container.
   * @returns {PIXI.Container}
   */
  #initializeHighlights() {
    const highlights = new PIXI.Container();
    highlights.filters = game.user.isGM ? [] : [VisionMaskFilter.create()];
    highlights.filterArea = canvas.app.screen;
    highlights.sortableChildren = true;
    highlights.eventMode = "none";
    return highlights;
  }

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

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
    this._cancelPlacement();
  }

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

  /** @inheritDoc */
  storeHistory(type, data, options) {
    if ( type === "update" ) {
      for ( const d of data ) delete d.behaviors;
    }

    // Non-GMs cannot create behaviors
    else if ( (type === "delete") && !game.user.isGM ) {
      for ( const d of data ) d.behaviors = [];
    }

    super.storeHistory(type, data, options);
  }

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

  /** @inheritDoc */
  copyObjects(options) {
    this._shapeClipboard.shape = null;
    return super.copyObjects(options);
  }

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


  /**
   * Copy (or cut) currently hovered shape.
   * @param {object} [options]             Additional options
   * @param {boolean} [options.cut=false]  Cut instead of copy? Default is false.
   */
  #copyShape({cut=false}={}) {
    this.clipboard.objects = [];
    this._shapeClipboard.shape = this.hover?.hoveredHandle?.controls.shape.clone() ?? null;
    this._shapeClipboard.cut = cut;
    if ( !this._shapeClipboard.shape ) return;
    ui.notifications.info(cut ? "REGION.NOTIFICATIONS.CutShape" : "REGION.NOTIFICATIONS.CopiedShape", {localize: true});
  }

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

  /**
   * Paste the copied/cut shape.
   * @param {Point} position               The destination of the shape
   * @param {object} [options]             Options which modify the paste operation
   * @param {boolean} [options.snap=true]  Snap the resulting objects to the grid. Default is true.
   */
  #pasteShape(position, {snap=true}={}) {
    let {shape, cut} = this._shapeClipboard;
    if ( !shape ) return;
    if ( !this.controlled.length ) {
      ui.notifications.error("REGION.NOTIFICATIONS.PastedShapeWithoutControlledRegion", {localize: true});
      return;
    }
    const region = this.controlled[0];
    const shapes = region.document.toObject().shapes;
    if ( cut ) {
      this._shapeClipboard.shape = null;
      if ( shape.parent === region.document ) shapes.splice(shape._index, 1);
      else {
        const shapes = shape.parent.toObject().shapes;
        shapes.splice(shape._index, 1);
        shape.parent.update({shapes});
      }
    }
    shape = shape.clone({}, {parent: region.document});
    shape.move(position, {snap});
    shapes.push(shape.toObject());
    region.document.update({shapes}, {isPaste: true});
    ui.notifications.info("REGION.NOTIFICATIONS.PastedShape", {localize: true});
  }

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

  /** @override */
  getZIndex() {
    return this.active ? this.options.zIndexActive : this.options.zIndex;
  }

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

  /** @inheritDoc */
  async _draw(options) {
    this.addChild(this._highlights);
    await super._draw(options);
    this.objects.sortChildren = RegionLayer.#sortRegions;
    this.objects.visible = true;
    this.#highlight = this.addChild(new PIXI.Graphics());
    this.#highlight.eventMode = "none";
    this.#highlight.visible = false;

    /** @deprecated since v13 */
    for ( const region of canvas.scene.regions ) {
      for ( const behavior of region.behaviors ) {
        if ( behavior.hasEvent("behaviorStatus") ) {
          foundry.utils.logCompatibilityWarning(`RegionBehavior [${behavior.uuid}] subscribes to the BEHAVIOR_STATUS event, `
            + "which is deprecated in favor of BEHAVIOR_ACTIVATED, BEHAVIOR_DEACTIVATED, BEHAVIOR_VIEWED, and BEHAVIOR_UNVIEWED.", {since: 13, until: 15});
        }
        if ( behavior.hasEvent("tokenMove") ) {
          foundry.utils.logCompatibilityWarning(`RegionBehavior [${behavior.uuid}] subscribes to the TOKEN_MOVE event, `
            + "which is deprecated in favor of TOKEN_MOVE_WITHIN and is no longer triggered.", {since: 13, until: 15});
        }
        if ( behavior.hasEvent("tokenPreMove") ) {
          foundry.utils.logCompatibilityWarning(`RegionBehavior [${behavior.uuid}] subscribes to the TOKEN_PRE_MOVE event, `
            + "which is deprecated without replacement and is no longer triggered.", {since: 13, until: 15});
        }
      }
    }
  }

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

  /** @inheritDoc */
  async _tearDown(options) {
    this._cancelPlacement();
    this.removeChild(this._highlights);
    return super._tearDown(options);
  }

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

  /**
   * Highlight the shape or clear the highlight.
   * @param {BaseShapeData|null} shape    The shape to highlight, or null to clear the highlight
   * @internal
   */
  _highlightShape(shape) {
    this.#highlight.clear();
    this.#highlight.visible = false;
    if ( !shape ) return;
    const s = canvas.dimensions.uiScale;
    const thickness = CONFIG.Canvas.objectBorderThickness * s;
    const dash = thickness * 2;
    const gap = dash * (5 / 8);
    this.#highlight.visible = true;
    this.#highlight.lineStyle({
      width: thickness,
      color: 0x000000,
      join: PIXI.LINE_JOIN.ROUND,
      shader: new PIXI.smooth.DashLineShader({dash, gap})
    });
    shape.drawShape(this.#highlight);
  }

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

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    const grid = canvas.grid ?? foundry.documents.BaseScene.defaultGrid;
    return {
      name: "regions",
      order: 7,
      title: "CONTROLS.GroupRegion",
      layer: "regions",
      icon: "fa-regular fa-game-board",
      visible: game.user.can("REGION_CREATE"),
      onChange: (event, active) => {
        if ( active ) canvas.regions.activate();
      },
      onToolChange: (event, tool, active) => {
        canvas.regions.setAllRenderFlags({refreshState: true});
        if ( !active ) return;
        if ( !tool.control ) canvas.regions.releaseAll();
        if ( !canvas.regions.templateMode ) {
          if ( tool.creation && canvas.regions.controlled.length ) {
            canvas.regions._togglePaletteVisible = false;
            if ( "togglePalette" in ui.controls.tools ) ui.controls.render({ reset: true });
          } else {
            canvas.regions._togglePaletteVisible = true;
            if ( !("togglePalette" in ui.controls.tools) ) ui.controls.render({ reset: true });
          }
        }
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.RegionSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            src: "toolclips/tools/region-select.webm",
            heading: "CONTROLS.RegionSelect",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionSelectP"}, "selectAlt", "selectMultiple",
              "move", "rotate", "edit", "delete"])
          }
        },
        templateMode: {
          name: "templateMode",
          order: 2,
          title: "CONTROLS.RegionTemplateMode",
          icon: "fa-solid fa-ruler-combined",
          toggle: true,
          active: !!canvas.regions?.templateMode,
          toolclip: {
            heading: "CONTROLS.RegionTemplateMode",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionTemplateModeP"}])
          },
          onChange: (event, toggled) => {
            canvas.regions.templateMode = toggled;
          }
        },
        rectangle: {
          name: "rectangle",
          order: 3,
          title: "CONTROLS.RegionRectangle",
          icon: "fa-solid icon-fa-rectangle",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "rectangle", x: 0, y: 0, width: 0, height: 0},
          toolclip: {
            heading: "CONTROLS.RegionRectangle",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw", "drawProportionally",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        circle: {
          name: "circle",
          order: 4,
          title: "CONTROLS.RegionCircle",
          icon: "fa-solid fa-circle",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "circle", x: 0, y: 0, radius: 0},
          toolclip: {
            heading: "CONTROLS.RegionCircle",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw", "drawProportionally",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        ellipse: {
          name: "ellipse",
          order: 5,
          title: "CONTROLS.RegionEllipse",
          icon: "fa-solid icon-fa-ellipse",
          visible: !canvas.regions?.templateMode,
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "ellipse", x: 0, y: 0, radiusX: 0, radiusY: 0},
          toolclip: {
            heading: "CONTROLS.RegionEllipse",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw", "drawProportionally",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        cone: {
          name: "cone",
          order: 6,
          title: "CONTROLS.RegionCone",
          icon: "fa-solid icon-fa-cone",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "cone", x: 0, y: 0, radius: 0, angle: 60},
          toolclip: {
            heading: "CONTROLS.RegionCone",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        ring: {
          name: "ring",
          order: 7,
          title: "CONTROLS.RegionRing",
          icon: "fa-regular fa-circle",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "ring", x: 0, y: 0, radius: 0, innerWidth: 0, outerWidth: grid.size},
          toolclip: {
            heading: "CONTROLS.RegionRing",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        line: {
          name: "line",
          order: 8,
          title: "CONTROLS.RegionLine",
          icon: "fa-solid fa-horizontal-rule",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "line", x: 0, y: 0, length: 0, width: grid.size},
          toolclip: {
            heading: "CONTROLS.RegionLine",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        emanation: {
          name: "emanation",
          order: 9,
          title: "CONTROLS.RegionEmanation",
          icon: "fa-solid fa-podcast",
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "emanation", base: {type: "token", x: 0, y: 0, width: 1, height: 1, shape:
            grid.isHexagonal ? CONST.TOKEN_SHAPES.ELLIPSE_1 : CONST.TOKEN_SHAPES.RECTANGLE_1}, radius: 0},
          toolclip: {
            heading: "CONTROLS.RegionEmanation",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              "draw",
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        polygon: {
          name: "polygon",
          order: 10,
          title: "CONTROLS.RegionPolygon",
          icon: "fa-solid fa-draw-polygon",
          visible: !canvas.regions?.templateMode,
          control: !canvas.regions?.templateMode,
          creation: true,
          shapeData: {type: "polygon", points: [0, 0, 0, 0]},
          toolclip: {
            heading: "CONTROLS.RegionPolygon",
            items: sc.buildToolclipItems([
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionShape"} : "",
              {heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP"},
              !canvas.regions?.templateMode ? {paragraph: "CONTROLS.RegionPerformance"} : ""
            ])
          }
        },
        hole: {
          name: "hole",
          order: 11,
          title: "CONTROLS.RegionHole",
          icon: "fa-duotone fa-object-subtract",
          toggle: true,
          visible: !canvas.regions?.templateMode,
          active: false,
          toolclip: {
            heading: "CONTROLS.RegionHole",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionHoleP"}])
          }
        },
        snap: {
          name: "snap",
          order: 12,
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          visible: !canvas.grid?.isGridless,
          active: canvas.forceSnapVertices,
          onChange: (event, toggled) => canvas.forceSnapVertices = toggled,
          toolclip: {
            heading: "CONTROLS.CommonForceSnap",
            items: sc.buildToolclipItems([{paragraph: "CONTROLS.RegionSnap"}, "draw", "drawProportionally"])
          }
        },
        togglePalette: {
          ...this.TOGGLE_PALETTE,
          order: 13,
          visible: canvas.regions?._togglePaletteVisible
        },
        clear: {
          name: "clear",
          order: 14,
          title: "CONTROLS.RegionClear",
          icon: "fa-solid fa-trash",
          onChange: () => canvas.regions.deleteAll(),
          button: true
        }
      },
      activeTool: "select"
    };
  }

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Place a Region at the cursor.
   * The Region can have multiple shapes but must have at least one.
   * Each shape is placed one after the other in the given order.
   * Only one Region can be placed at a time.
   * The placed Region shapes can be rotated with the mouse wheel unless `allowRotation` is false.
   * Left-click confirms the placement of a shape. Right-click skips the placement of a shape.
   * The Region layer is activated unless the Token layer is active.
   * @param {Partial<RegionData>} data          The data of the Region to place
   * @param {RegionPlacementOptions} [options]  Additional options
   * @returns {Promise<RegionDocument|null>}    The Region document that was placed or null if
   *   - the placements of all shapes were skipped unless `allowEmpty` is true,
   *   - the dismiss key was pressed,
   *   - the placement was rejected by `preCommit`,
   *   - the game was paused, the user is not a GM, and the `create` option is true, or
   *   - the Region creation was rejected by preCreate.
   * @example Attach a 10-foot emanation to a token.
   * ```js
   * await canvas.regions.placeRegion({
   *   name: "Aura",
   *   shapes: [{
   *     type: "emanation",
   *     base: {type: "token", x: 0, y: 0, width: 1, height: 1, shape: CONST.TOKEN_SHAPES.RECTANGLE_1},
   *     radius: 10 * canvas.dimensions.distancePixels,
   *     gridBased: true
   *   }],
   *   color: game.user.color,
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id],
   *   highlightMode: "coverage",
   *   displayMeasurements: true,
   *   visibility: CONST.REGION_VISIBILITY.ALWAYS,
   *   ownership: {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER}
   * }, {attachToToken: true});
   * ```
   * @example Place four 40-foot radius circles.
   * ```js
   * const radius = 40 * canvas.dimensions.distancePixels;
   * const shapes = [];
   * for ( let i = 0; i < 4; i++ ) shapes.push({type: "circle", x: 0, y: 0, radius, gridBased: true});
   * await canvas.regions.placeRegion({
   *   name: "Meteor Swarm",
   *   shapes,
   *   color: game.user.color,
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id],
   *   highlightMode: "coverage",
   *   displayMeasurements: true,
   *   visibility: CONST.REGION_VISIBILITY.ALWAYS,
   *   ownership: {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER}
   * });
   * ```
   * @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 placeRegion(data, {create=true, createOptions={}, allowRotation=true, allowEmpty=false, attachToToken=false,
    onMove, onRotate, onChange, preConfirm, preSkip, preCommit, /** @private */ _destroyPreview=true,
    /** @private */ _regionIndex=0, /** @private */ _regionCount=1}={}) {
    if ( onMove && (typeof onMove !== "function") ) throw new Error("The onMove argument must be a function.");
    if ( onRotate && (typeof onRotate !== "function") ) throw new Error("The onRotate argument must be a function.");
    if ( onChange && (typeof onChange !== "function") ) throw new Error("The onChange argument must be a function.");
    if ( preConfirm && (typeof preConfirm !== "function") ) throw new Error("The preConfirm argument must be a function.");
    if ( preSkip && (typeof preSkip !== "function") ) throw new Error("The preSkip argument must be a function.");
    if ( preCommit && (typeof preCommit !== "function") ) throw new Error("The preCommit argument must be a function.");
    if ( !canvas.ready ) throw new Error("The canvas is not ready.");

    // Create preview document
    const document = new CONFIG.Region.documentClass(foundry.utils.deepClone(data), {parent: canvas.scene});
    const shapes = document.shapes.map(s => s.clone());

    if ( !shapes.length ) throw new Error("The Region must have at least one shape.");
    if ( attachToToken ) {
      if ( shapes.length !== 1 ) throw new Error("Only a single Region shape can be attached to a Token.");
      if ( document.attachment.token ) throw new Error("The Region is already attached to a Token.");
    }
    if ( document.attachment.token ) {
      if ( !document._source.levels.equals([document.attachment.token.level]) ) {
        throw new Error("A Region attached to a Token must be included only in the Token's level.");
      }
      if ( document.hidden !== document.attachment.token.hidden ) {
        throw new Error("A Region attached to a Token must have the same hidden state as the Token.");
      }
    }
    if ( document.restriction.enabled && (document.levels.size !== 1) ) {
      throw new Error("A restricted Region must be included only in one Level.");
    }
    if ( create && !document.canUserModify(game.user, "create") ) throw new Error("You do not have the necessary permissions to create this Region.");

    // Cancel the current placement if there is one
    this._cancelPlacement();
    canvas.tokens._cancelPlacement();

    // Cancel the current movement planning if there is one
    canvas.tokens._cancelMovementPlanning();

    // Cancel the current mouse wheel rotation if there is one
    this._cancelMouseWheel();

    // Cancel current drag workflow
    if ( canvas.currentMouseManager ) {
      canvas.currentMouseManager.interactionData.cancelled = true;
      canvas.currentMouseManager.cancel();
    }

    // Non-GMs cannot create a region while the game is paused
    if ( create && game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return null;
    }

    // Activate region or token layer
    const layer = canvas.tokens.active ? canvas.tokens : this;
    layer.activate({tool: "select"});

    // Initialize shapes
    data = document.toObject();
    const shape = shapes[0];
    document.updateSource({shapes: [shape], _shapeConstraints: null});

    // Create preview object
    const preview = new CONFIG.Region.objectClass(document);
    preview._previewType = "creation";
    document._object = preview;

    // Create placement context
    const {promise, resolve, reject} = Promise.withResolvers();
    this._placementContext = {data, layer, preview, regionIndex: _regionIndex, regionCount: _regionCount,
      shapes, shape, create, createOptions: foundry.utils.deepClone(createOptions),
      destroyPreview: _destroyPreview, allowRotation, allowEmpty, attachToToken, onMove, onRotate, onChange,
      preConfirm, preSkip, preCommit, resolve, reject, rotationNotification: false};

    // Register event listeners
    canvas.stage.on("pointermove", this.#placementPointerMoveListener);
    canvas.stage.on("pointerdown", this.#placementPointerDownListener);
    MouseInteractionManager.emulateMoveEvent();

    // Release other regions and refresh token/region state
    this.releaseAll();
    layer.setAllRenderFlags({refreshState: true});

    // Allow interaction with tokens if necessary
    if ( attachToToken && !canvas.tokens.active ) {
      canvas.tokens.interactiveChildren = true;
      canvas.tokens.setAllRenderFlags({refreshState: true});
    }

    return promise;
  }

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

  /**
   * Place one or multiple Regions at the cursor.
   * The Region can have multiple shapes but must have at least one.
   * Each Region is placed one after the other in the given order.
   * Each shape of a Region is placed one after the other in the given order.
   * The placed Region shapes can be rotated with the mouse wheel unless `allowRotation` is false.
   * Left-click confirms the placement of a shape. Right-click skips the placement of a shape.
   * @param {Iterable<Partial<RegionData>>} data The data of the Regions to place
   * @param {RegionPlacementOptions} [options]   Additional options
   * @returns {Promise<RegionDocument[]|null>}   The Region documents that were placed and not rejected by preCreate,
   *   or null if
   *   - the placement was rejected by `preCommit`,
   *   - the dismiss key was pressed, or
   *   - the game was paused, the user is not a GM, and the `create` option is true.
   * @example Place three 20-foot token emanations.
   * ```js
   * const data = [];
   * for ( let i = 0; i < 3; i++ ) data.push({
   *   name: `Emanation (${i + 1})`,
   *   shapes: [{
   *     type: "emanation",
   *     base: {
   *       type: "token",
   *       x: 0,
   *       y: 0,
   *       width: 1,
   *       height: 1,
   *       shape: CONST.TOKEN_SHAPES.ELLIPSE_1
   *     },
   *     radius: 20 * canvas.dimensions.distancePixels,
   *     gridBased: true
   *   }],
   *   restriction: {enabled: true},
   *   levels: [canvas.level.id],
   *   displayMeasurements: true,
   *   visibility: CONST.REGION_VISIBILITY.ALWAYS,
   *   ownership: {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER}
   * });
   * await canvas.regions.placeRegions(data, {attachToToken: true});
   */
  async placeRegions(data, {create=true, createOptions={}, allowRotation=true, allowEmpty=false, attachToToken=false,
    onMove, onRotate, onChange, preConfirm, preSkip, preCommit}={}) {
    const regionCount = data.length;
    if ( !regionCount ) throw new Error("There must be at least one Region to be placed.");
    if ( preCommit && (typeof preCommit !== "function") ) throw new Error("The preCommit argument must be a function.");
    const documents = [];
    const previews = [];
    try {
      data = Array.from(data, data => {
        data = foundry.utils.deepClone(data);

        // Multiple previews need a unique PlaceableObject#objectId and #sourceId
        data._id ||= foundry.utils.randomID();
        return data;
      });
      createOptions = foundry.utils.deepClone(createOptions);
      for ( const [regionIndex, d] of data.entries() ) {
        const document = await this.placeRegion(d, {create: false, allowEmpty: true, allowRotation, attachToToken,
          onMove, onRotate, onChange, preConfirm, preSkip, _destroyPreview: false, _regionIndex: regionIndex,
          _regionCount: regionCount});
        if ( !document ) return null;
        const preview = document.object;
        if ( document.shapes.length || allowEmpty ) {
          documents.push(document);
          previews.push(preview);
        }
        else if ( !preview.destroyed ) preview.destroy({children: true});
      }
      if ( preCommit ) {
        const allowed = await preCommit(Object.freeze(documents));
        if ( (allowed !== undefined) && !allowed ) return null;
      }
      let regions;
      if ( documents.length ) {
        if ( create ) {
          const scene = documents[0].parent;
          regions = await scene.createEmbeddedDocuments("Region", documents.map(d => d.toObject()),
            {controlObject: true, ...createOptions, parent: scene});
        } else {
          regions = documents;
          for ( const region of regions ) {
            region._object = null;
            region._destroyed = false;
          }
        }
      }
      return regions ?? [];
    } finally {
      for ( const preview of previews ) {
        if ( !preview.destroyed ) preview.destroy({children: true});
      }
    }
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _createDragPreviewData(event) {
    let data = {};
    if ( game.user.isGM ) data.name = RegionDocument.implementation.defaultName({parent: canvas.scene});
    else {
      const takenNames = new Set();
      for ( const region of canvas.scene.regions ) {
        if ( region.isOwner ) takenNames.add(region.name);
      }
      const baseName = `${_loc(RegionDocument.implementation.metadata.label)} [${game.user.name}]`;
      let name = baseName;
      let index = 1;
      while ( takenNames.has(name) ) name = `${baseName} (${++index})`;
      data.name = name;
    }
    if ( this.templateMode ) {
      data.color = game.user.color;
      data.levels = [canvas.level.id];
      data.restriction = {enabled: true, type: "move"};
      data.visibility = CONST.REGION_VISIBILITY.ALWAYS;
      data.highlightMode = "coverage";
      data.displayMeasurements = true;
      data.ownership = {[game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER};
    }
    else data = foundry.utils.mergeObject(this.paletteCreateData, data, { inplace: false });
    return data;
  }

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

  /** @inheritDoc */
  _updateDragPreview(event) {
    super._updateDragPreview(event);
    event.interactionData.preview.document.updateShapeConstraints();
  }

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

  /** @override */
  _onDeleteKey(event) {
    if ( !this.controlled.length ) return false;
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return true;
    }

    // Delete only the shape if its control handle is hovered
    if ( this.hover?.hoveredHandle ) {
      const shape = this.hover.hoveredHandle.controls.shape;
      const document = shape.parent;
      if ( document.behaviors.size ) document.removeShapeDialog(shape);
      else document.update({shapes: document.shapes.toSpliced(shape._index, 1)});
      return true;
    }

    // Otherwise delete the entire region
    return super._onDeleteKey(event);
  }

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

  /** @override */
  _onCutKey(event) {
    if ( this.hover?.hoveredHandle ) this.#copyShape({cut: true});
    else this.copyObjects({cut: true});
    return true;
  }

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

  /** @override */
  _onCopyKey(event) {
    if ( this.hover?.hoveredHandle ) this.#copyShape();
    else this.copyObjects();
    return true;
  }

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

  /** @override */
  _onPasteKey(event) {
    if ( this._shapeClipboard.shape ) {
      this.#pasteShape(canvas.mousePosition, {snap: !event.shiftKey});
      return true;
    }
    if ( this.clipboard.objects.length ) {
      this.pasteObjects(canvas.mousePosition, {hidden: event.altKey, snap: !event.shiftKey});
      return true;
    }
    return true;
  }

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

  /** @inheritDoc */
  _onMouseWheel(event) {
    if ( this._placementContext ) {
      const {preview, regionIndex, regionCount, shapes, shape, allowRotation, onRotate,
        onChange} = this._placementContext;
      if ( !allowRotation ) {
        if ( this._placementContext.rotationNotification ) return;
        ui.notifications.warn("REGION.NOTIFICATIONS.PlacedRegionRotationNotAllowed", {localize: true});
        this._placementContext.rotationNotification = true;
        return;
      }
      const shapeIndex = shape._index;
      const shapeCount = shapes.length;
      const document = preview.document;
      const precise = !event.shiftKey;
      if ( !onRotate || (onRotate({event, preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount,
        precise}) !== false) ) {
        const interval = shape.type === "grid" ? (canvas.grid.isSquare ? 90 : 60) : (precise ? 5 : 15);
        const delta = interval * Math.sign(event.delta);
        shape.rotate((shape.rotation + delta).toNearest(interval) - shape.rotation);
      }
      const diff = document.updateSource({shapes: [...document.shapes.slice(0, -1), shape]});
      if ( foundry.utils.isEmpty(diff) ) return;
      document.updateShapeConstraints();
      preview.renderFlags.set({refreshShapes: true});
      if ( onChange ) onChange({preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
      return;
    }

    super._onMouseWheel(event);
  }

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

  /** @inheritDoc */
  _onDismissKey(event) {
    if ( this._placementContext ) {
      this._cancelPlacement();
      return true;
    }
    return super._onDismissKey(event);
  }

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

  /**
   * Called when the placement is moved.
   * @param {PIXI.FederatedEvent} event    The pointer event
   */
  #movePlacement(event) {
    const {preview, regionIndex, regionCount, shapes, shape, onMove, onChange, attachToToken} = this._placementContext;
    const document = preview.document;
    const shapeIndex = shape._index;
    const shapeCount = shapes.length;
    if ( !preview.parent ) {
      this.preview.addChild(preview);
      preview.draw();
    }
    const position = event.getLocalPosition(this);
    const snap = !event.shiftKey;

    // Invoke a custom onMove handler, potentially preventing default handling
    const preventMove = onMove?.({event, preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount,
      position, snap}) === false;
    if ( !preventMove ) {
      if ( attachToToken ) this.#attachPlacementToToken(position);
      else shape.move(position, {snap});
    }
    const diff = document.updateSource({shapes: [...document.shapes.slice(0, -1), shape]});
    if ( foundry.utils.isEmpty(diff) ) return;
    document.updateShapeConstraints();
    preview.renderFlags.set({refreshShapes: true});
    if ( onChange ) onChange({preview, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
  }

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

  /**
   * Called if the `attachToToken` placement option is true on move.
   * @param {Point} position    The mouse position
   */
  #attachPlacementToToken(position) {
    const {preview, data, shape} = this._placementContext;
    const document = preview.document;
    const tokenEmanation = (shape.type === "emanation") && (shape.base.type === "token");
    const hoveredToken = canvas.tokens.hover?.document;
    if ( hoveredToken ) {
      let elevation;
      if ( tokenEmanation ) {
        const source = hoveredToken._source;
        elevation = {
          bottom: Number.isFinite(data.elevation.bottom) ? data.elevation.bottom + source.elevation : null,
          top: Number.isFinite(data.elevation.top) ? data.elevation.top + (source.elevation
            + (source.depth * canvas.grid.distance)) : null
        };
        shape.updateSource({
          base: {
            x: source.x,
            y: source.y,
            width: source.width,
            height: source.height,
            shape: source.shape
          }
        });
      } else {
        const center = hoveredToken.getMovementOrigin(hoveredToken._source);
        elevation = {
          bottom: Number.isFinite(data.elevation.bottom) ? data.elevation.bottom + center.elevation : null,
          top: Number.isFinite(data.elevation.top) ? data.elevation.top + center.elevation : null
        };
        shape.move(center);
      }
      document.updateSource({elevation, levels: [hoveredToken._source.level], hidden: hoveredToken.hidden,
        attachment: {token: hoveredToken.id}});
    } else {
      if ( tokenEmanation ) {
        const original = data.shapes[shape._index].base;
        shape.updateSource({base: {width: original.width, height: original.height, shape: original.shape}});
      }
      shape.move(position, {snap: true});
      document.updateSource({elevation: data.elevation, levels: data.levels, hidden: data.hidden,
        attachment: {token: null}});
    }
  }

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

  /**
   * Confirm the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #confirmPlacement(event) {
    const {preview, regionIndex, regionCount, shape, shapes, preConfirm} = this._placementContext;
    const document = preview.document;
    const shapeIndex = shape._index;
    const shapeCount = shapes.length;
    if ( preConfirm ) {
      const allowed = preConfirm({event, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
      if ( allowed === false ) return;
    }
    this.#nextPlacement(event);
  }

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

  /**
   * Skip the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  #skipPlacement(event) {
    const {preview, regionIndex, regionCount, shapes, shape, preSkip} = this._placementContext;
    const document = preview.document;
    const shapeIndex = shape._index;
    const shapeCount = shapes.length;
    if ( preSkip ) {
      const allowed = preSkip({event, document, regionIndex, regionCount, shape, shapeIndex, shapeCount});
      if ( allowed === false ) return;
    }
    document.updateSource({
      shapes: document.shapes.slice(0, -1),
      _shapeConstraints: document._shapeConstraints?.slice(0, -1) ?? null
    });
    preview.renderFlags.set({redraw: true});
    this.#nextPlacement(event);
  }

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

  /**
   * Begin next placement or resolve if there's no next shape.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #nextPlacement(event) {
    const {preview, shapes} = this._placementContext;
    let {shape} = this._placementContext;
    const document = preview.document;

    // If not all shapes have been placed yet, begin placement of the next shape
    if ( shape._index < shapes.length - 1 ) {
      shape = this._placementContext.shape = shapes[shape._index + 1];
      document.updateSource({shapes: [...document.shapes, shape]});
      preview.renderFlags.set({redraw: true});
      this.#movePlacement(event);
      return;
    }

    // Otherwise finish placement and create or return the region document
    const {preCommit, allowEmpty, resolve, reject, create, createOptions, destroyPreview} = this._placementContext;
    this.#finishPlacement();
    let region;
    try {
      if ( document.shapes.length || allowEmpty ) {
        let allowed = true;
        if ( preCommit ) {
          allowed = await preCommit(Object.freeze([document]));
          if ( allowed === undefined ) allowed = true;
        }
        if ( !allowed ) region = null;
        else if ( create ) {
          const scene = document.parent;
          region = await CONFIG.Region.documentClass.create(document.toObject(),
            {controlObject: true, ...createOptions, parent: scene});
          if ( !region ) region = null;
        } else {
          region = document;
          if ( destroyPreview ) {
            region._object = null;
            region._destroyed = false;
          }
        }
      }
      resolve(region);
    } catch(err) {
      reject(err);
    } finally {
      if ( (!region || destroyPreview) && !preview.destroyed ) preview.destroy({children: true});
    }
  }

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

  /**
   * Cancel the placement.
   * @internal
   */
  _cancelPlacement() {
    if ( !this._placementContext ) return;
    const {preview, resolve} = this._placementContext;
    if ( !preview.destroyed ) preview.destroy({children: true});
    this.#finishPlacement();
    resolve(null);
  }

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

  /**
   * Reset the placement context and unregister all its event listeners.
   */
  #finishPlacement() {
    canvas.stage.off("pointermove", this.#placementPointerMoveListener);
    canvas.stage.off("pointerdown", this.#placementPointerDownListener);
    const {attachToToken} = this._placementContext;
    this._placementContext.layer.setAllRenderFlags({refreshState: true});
    this._placementContext = null;
    if ( attachToToken && !canvas.tokens.active ) {
      canvas.tokens.interactiveChildren = false;
      canvas.tokens.setAllRenderFlags({refreshState: true});
    }
  }

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

  /** @inheritDoc */
  _confirmDeleteKey(documents) {
    if ( documents.every(d => !d.behaviors.size) ) return true;
    return super._confirmDeleteKey(documents);
  }
}
