import PlaceablesLayer from "./base/placeables-layer.mjs";
import CanvasAnimation from "../animation/canvas-animation.mjs";
import DialogV2 from "../../applications/api/dialog.mjs";
import SceneControls from "../../applications/ui/scene-controls.mjs";
import {ActiveEffect, Actor, TokenDocument} from "../../documents/_module.mjs";
import MouseInteractionManager from "../interaction/mouse-handler.mjs";

/**
 * @import {Rectangle, TokenFindMovementPathOptions, TokenPlannedMovement} from "../../_types.mjs";
 * @import Token from "../placeables/token.mjs";
 * @import User from "@client/documents/user.mjs";
 * @import {TokenData, TokenPosition} from "@common/documents/_types.mjs";
 * @import {TokenMovementOptions, TokenMovementWaypoint} from "@client/documents/_types.mjs";
 */

/**
 * The Tokens Container.
 * @category Canvas
 */
export default class TokenLayer extends PlaceablesLayer {
  constructor() {
    super();

    /**
     * The ruler paths.
     * @type {PIXI.Container}
     * @internal
     */
    this._rulerPaths = new PIXI.Container();
    this._rulerPaths.eventMode = "none";
  }

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

  /**
   * The current index position in the tab cycle
   * @type {number|null}
   * @internal
   */
  _tabIndex = null;

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

  /**
   * The Token that the drag workflow was initiated on, if there's a drag workflow in progress.
   * Set in {@link foundry.canvas.placeables.Token#_onDragLeftStart} and
   * {@link foundry.canvas.placeables.Token#_onDragLeftCancel}.
   * @type {Token|null}
   * @internal
   */
  _draggedToken = null;

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

  /**
   * The currently selected movement action override.
   * @type {string|null}
   * @internal
   */
  _dragMovementAction = null;

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

  /**
   * The movement planning context.
   * @type {{
   *   object: Token;
   *   allowedActions: string[]|null;
   *   direct: boolean;
   *   minCost: number;
   *   maxCost: number;
   *   minDistance: number;
   *   maxDistance: number;
   *   preventDrop: boolean;
   *   terrainOptions: Omit<TokenCreateTerrainMovementPathOptions, "preview">;
   *   constrainOptions: Omit<TokenConstrainMovementPathOptions, "preview"|"history"|"measureOptions">;
   *   measureOptions: Omit<TokenMeasureMovementPathOptions, "preview">;
   *   pathfindingOptions: Omit<TokenFindMovementPathOptions, "preview"|"terrainOptions"|"constrainOptions"|
   *     "measureOptions">;
   *   moveOptions: Omit<TokenMovementOptions, "id"|"method"|"terrainOptions"|"constrainOptions"|"measureOptions"|
   *     "planned">;
   *   result: {
   *     id: string;
   *     origin: TokenPosition;
   *     destination: TokenPosition;
   *     waypoints: TokenMovementWaypoint[];
   *   }|null;
   *   resolve: (document: {
   *     id: string;
   *     origin: TokenPosition;
   *     destination: TokenPosition;
   *     waypoints: TokenMovementWaypoint[];
   *   }|null) => void;
   *   reject: (error: Error) => void;
   *   violations: string[];
   * }|null}
   * @internal
   */
  _movementPlanningContext = null;

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

  /**
   * The placement context.
   * @type {{
   *   previews: Token[];
   *   placed: TokenDocument[];
   *   index: number;
   *   resolve: (document: TokenDocument[]) => void;
   *   reject: (error: Error) => void;
   *   create: boolean;
   *   createOptions: Partial<Omit<DatabaseCreateOperation, "parent">>;
   *   allowRotation: boolean;
   *   onMove: (args: {event: PIXI.FederatedEvent; preview: Token;  document: TokenDocument;
   *     index: number; count: number; position: Point; snap: boolean}) => boolean|void;
   *   onRotate: (args: {event: WheelEvent; preview: Token; document: TokenDocument;
   *     index: number; count: number; precise: boolean}) => boolean|void;
   *   onChange: (args: {preview: Token; document: TokenDocument; index: number;
   *     count: number}) => void;
   *   preConfirm: (args: {event: PIXI.FederatedEvent; document: TokenDocument;
   *     index: number; count: number}) => boolean|void;
   *   preSkip: (args: {event: PIXI.FederatedEvent; document: TokenDocument;
   *     index: number; count: number}) => boolean|void;
   *   preCommit: (documents: ReadonlyArray<TokenDocument>) => Promise<any|void>|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);
  };

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

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "tokens",
      controllableObjects: true,
      rotatableObjects: true,
      keyboardMovableObjects: true,
      confirmDeleteKey: true,
      zIndex: 200
    });
  }

  /** @inheritdoc */
  static documentName = "Token";

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

  /**
   * The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}).
   * @type {number}
   */
  get occlusionMode() {
    return this.#occlusionMode;
  }

  set occlusionMode(value) {
    if ( this.#occlusionMode === value ) return;
    this.#occlusionMode = value;
    canvas.perception.update({refreshOcclusion: true});
  }

  #occlusionMode;

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

  /**
   * Called when the fog is explored.
   * @type {() => void}
   */
  #onFogExplored = () => this.recalculatePlannedMovementPaths();

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

  /** @inheritdoc */
  get hookName() {
    return TokenLayer.name;
  }

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

  /** @override */
  get hud() {
    return canvas.hud.token;
  }

  /**
   * An Array of tokens which belong to actors which are owned
   * @type {Token[]}
   */
  get ownedTokens() {
    return this.placeables.filter(t => t.actor && t.actor.isOwner);
  }

  /**
   * A Set of Token objects which currently display a combat turn marker.
   * @type {Set<Token>}
   */
  turnMarkers = new Set();

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

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    return canvas.grid.getSnappedPoint({x: point.x, y: point.y}, {mode: M.TOP_LEFT_CORNER, resolution: 1});
  }

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

  /** @override */
  _prepareKeyboardMovementUpdates(objects, dx, dy, dz) {
    const updates = [];
    const movement = {};
    for ( const object of objects ) {
      updates.push({_id: object.id});
      const waypoint = object._getShiftedPosition(dx, dy, dz);
      waypoint.action = object._getKeyboardMovementAction();
      if ( canvas.grid.isGridless ) waypoint.snapped = false;
      else {
        const snapped = object.document.getSnappedPosition(waypoint);
        waypoint.snapped = (waypoint.x === Math.round(snapped.x))
          && (waypoint.y === Math.round(snapped.y))
          && waypoint.elevation.almostEqual(snapped.elevation);
      }
      waypoint.explicit = false;  // Keyboard movement does not explicitly place a waypoint
      waypoint.checkpoint = true;
      movement[object.id] = {waypoints: [waypoint], method: "keyboard"};
    }
    return [updates, {movement}];
  }

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

  /** @inheritDoc */
  async _draw(options) {
    this.addChild(this._rulerPaths);
    await super._draw(options);
    this.objects.visible = true;
    // Reset the Tokens layer occlusion mode for the Scene
    const M = CONST.TOKEN_OCCLUSION_MODES;
    this.#occlusionMode = game.user.isGM ? M.CONTROLLED | M.VISIBLE | M.HIGHLIGHTED : M.OWNED | M.VISIBLE;
    canvas.app.ticker.add(this.#animate, this);
    canvas.fog.addEventListener("explored", this.#onFogExplored);
  }

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

  /** @inheritDoc */
  async _tearDown(options) {
    this._cancelPlacement();
    this._cancelMovementPlanning();
    canvas.regions._cancelPlacement();
    this.removeChild(this._rulerPaths);
    this.concludeAnimation();
    canvas.fog.removeEventListener("explored", this.#onFogExplored);
    return super._tearDown(options);
  }

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

  /** @inheritDoc */
  _activate() {
    super._activate();
    if ( canvas.controls ) canvas.controls.doors.visible = true;
    this._tabIndex = null;
    canvas.perception.update({refreshOcclusionStates: true});
    canvas.notes.setAllRenderFlags({refreshState: true});
  }

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

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
    if ( canvas.controls ) canvas.controls.doors.visible = false;
    canvas.perception.update({refreshOcclusionStates: true});
    this._cancelPlacement();
    this._cancelMovementPlanning();
    canvas.regions._cancelPlacement();
    canvas.notes.setAllRenderFlags({refreshState: true});
  }

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

  /**
   * Target all Token instances which fall within a coordinate rectangle.
   * @param {Rectangle} rectangle                    The selection rectangle.
   * @param {object} [options]                      Additional options to configure targeting behaviour.
   * @param {boolean} [options.releaseOthers=true]  Whether or not to release other targeted tokens
   */
  targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
    const targets = [];
    const rectangle = new PIXI.Rectangle(x, y, width, height);
    for ( const token of this.placeables ) {
      if ( !token.visible || !token.renderable || !token.interactive ) continue;
      if ( token._overlapsSelection(rectangle) ) targets.push(token.id);
    }
    return this.setTargets(targets, {mode: releaseOthers ? "replace" : "acquire"});
  }

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

  /**
   * Assign multiple token targets
   * @param {string[]|Set<string>} targetIds    The array or set of Token IDs.
   * @param {object} [options]                  Additional options to configure targeting behaviour.
   * @param {"replace"|"acquire"|"release"} [options.mode="replace"]   The mode that determines the targeting behavior.
   *   - `"replace"` (default): Replace the current set of targeted Tokens with provided set of Tokens.
   *   - `"acquire"`: Acquire the given Tokens as targets without releasing already targeted Tokens.
   *   - `"release"`: Release the given Tokens as targets.
   */
  setTargets(targetIds, {mode="replace"}={}) {
    let targets = new Set();
    if ( mode !== "replace" ) {
      for ( const token of game.user.targets ) targets.add(token.id);
    }
    if ( mode === "release" ) {
      for ( const id of targetIds ) targets.delete(id);
    } else {
      for ( const id of targetIds ) {
        const token = this.get(id);
        if ( token ) targets.add(id);
      }
    }
    targets = Array.from(targets);
    game.user.broadcastActivity({targets});
    game.user._onUpdateTokenTargets(targets);
  }

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

  /**
   * Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene
   * Tokens are currently sorted in order of their TokenID
   *
   * @param {boolean} forwards  Which direction to cycle. A truthy value cycles forward, while a false value
   *                            cycles backwards.
   * @param {boolean} reset     Restart the cycle order back at the beginning?
   * @returns {Token|null}       The Token object which was cycled to, or null
   */
  cycleTokens(forwards, reset) {
    let next = null;
    if ( reset ) this._tabIndex = null;
    const order = this.#getCycleOrder();

    // If we are not tab cycling, try and jump to the currently controlled or impersonated token
    if ( this._tabIndex === null ) {
      this._tabIndex = 0;

      // Determine the ideal starting point based on controlled tokens or the primary character
      let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null;
      if ( !current && game.user.character ) {
        const actorTokens = game.user.character.getActiveTokens();
        current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null;
      }
      current = current || order[this._tabIndex] || null;

      // Either start cycling, or cancel
      if ( !current ) return null;
      next = current;
    }

    // Otherwise, cycle forwards or backwards
    else {
      if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0;
      else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1;
      next = order[this._tabIndex];
      if ( !next ) return null;
    }

    // Pan to the token and control it (if possible)
    canvas.animatePan({x: next.center.x, y: next.center.y});
    next.control();
    return next;
  }

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

  /**
   * Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left.
   * @returns {Token[]}
   */
  #getCycleOrder() {
    const observable = this.placeables.filter(token => {
      if ( game.user.isGM ) return true;
      if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false;
      return !token.document.hidden;
    });
    observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y));
    return observable;
  }

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

  /**
   * Immediately conclude the animation of any/all tokens
   */
  concludeAnimation() {
    this.placeables.forEach(t => t.stopAnimation());
    canvas.app.ticker.remove(this.#animate, this);
  }

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

  /**
   * Animate.
   * @param {number} deltaTime    The delta time
   */
  #animate(deltaTime) {
    this.#animateTargets();
    this.#animateTurnMarkers(deltaTime);
  }

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

  /**
   * Animate targeting arrows on targeted tokens.
   */
  #animateTargets() {
    if ( !game.user.targets.size ) return;
    if ( this._t === undefined ) this._t = 0;
    else this._t += canvas.app.ticker.elapsedMS;
    const duration = 2000;
    const pause = duration * .6;
    const fade = (duration - pause) * .25;
    const minM = .5; // Minimum margin is half the size of the arrow.
    const maxM = 1; // Maximum margin is the full size of the arrow.
    // The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds.
    const rm = maxM - minM;
    const t = this._t % duration;
    let dt = Math.max(0, t - pause) / (duration - pause);
    dt = CanvasAnimation.easeOutCircle(dt);
    const m = t < pause ? minM : minM + (rm * dt);
    const ta = Math.max(0, t - duration + fade);
    const a = 1 - (ta / fade);
    const config = {margin: m, alpha: a, border: {width: 2 * canvas.dimensions.uiScale}};
    for ( const t of game.user.targets ) t._drawTargetArrows(config);
  }

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

  /**
   * Animate the turn markers.
   * @param {number} deltaTime    The delta time
   */
  #animateTurnMarkers(deltaTime) {
    for ( const token of this.turnMarkers ) token.turnMarker?.animate(deltaTime);
  }

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

  /**
   * Recalculate the planned movement paths of all Tokens for the current User.
   */
  recalculatePlannedMovementPaths() {
    if ( !canvas.ready ) return;
    const contexts = this._draggedToken?.mouseInteractionManager.interactionData.contexts;
    if ( !contexts ) return;
    for ( const context of Object.values(contexts) ) context.token.recalculatePlannedMovementPath();
  }

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

  /**
   * Handle broadcast planned movement update.
   * @param {User} user    The User the planned movement data belongs to
   * @param {{[tokenId: string]: TokenPlannedMovement|null} | null} plannedMovements    The planned movement data
   * @internal
   */
  _updatePlannedMovements(user, plannedMovements) {

    // Clear all planned movement of this user
    if ( plannedMovements === null ) {
      for ( const token of this.placeables ) {
        if ( user.id in token._plannedMovement ) {
          delete token._plannedMovement[user.id];
          token.renderFlags.set({refreshRuler: true, refreshState: true});
        }
      }
    }

    // Update planned movement of this user
    else {
      for ( const tokenId in plannedMovements ) {
        const token = this.get(tokenId);
        if ( !token ) continue;
        const plannedMovement = plannedMovements[tokenId];

        // Remove planned movement from this token
        if ( plannedMovement === null ) {
          if ( user.id in token._plannedMovement ) {
            delete token._plannedMovement[user.id];
            token.renderFlags.set({refreshRuler: true, refreshState: true});
          }
        }

        // Update planned movement of this token
        else {
          token.renderFlags.set({refreshRuler: true, refreshState: !(user.id in token._plannedMovement)});
          token._plannedMovement[user.id] = plannedMovement;

          // Impute cost and create terrain data
          for ( const waypoints of [
            plannedMovement.foundPath,
            plannedMovement.unreachableWaypoints,
            plannedMovement.history
          ] ) {
            for ( const waypoint of waypoints ) {
              if ( waypoint.terrain ) waypoint.terrain = CONFIG.Token.movement.TerrainData.fromSource(waypoint.terrain);
              waypoint.cost ??= Infinity;
            }
          }
        }
      }
    }
  }

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

  /**
   * Provide an array of Tokens which are eligible subjects for tile occlusion.
   * By default, only tokens which are currently controlled or owned by a player are included as subjects.
   * @returns {Token[]}
   * @protected
   */
  _getOccludableTokens() {
    const M = CONST.TOKEN_OCCLUSION_MODES;
    const mode = this.occlusionMode;
    if ( (mode & M.HIGHLIGHTED) && this.highlightObjects ) {
      return this.placeables.filter(t => t.visible && t.interactive);
    }
    if ( (mode & M.VISIBLE) && canvas.effects.visionSources.some(s => s.active) ) {
      return this.placeables.filter(t => t.visible && t.interactive
        && (t.controlled || t.vision?.active || (t.document.disposition !== CONST.TOKEN_DISPOSITIONS.SECRET)));
    }
    const tokens = new Set();
    if ( (mode & M.HOVERED) && this.hover ) tokens.add(this.hover);
    if ( mode & M.CONTROLLED ) this.controlled.forEach(t => tokens.add(t));
    if ( mode & M.OWNED ) this.ownedTokens.filter(t => !t.document.hidden).forEach(t => tokens.add(t));
    return Array.from(tokens);
  }

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

  /** @inheritDoc */
  _getMovableObjects(ids, includeLocked) {
    const tokens = super._getMovableObjects(ids, includeLocked);
    return tokens.filter(token => !token._preventKeyboardMovement);
  }

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

  /** @inheritDoc */
  _getCopyableObjects(options) {
    if ( !game.user.can("TOKEN_CREATE") || (options.cut && !game.user.can("TOKEN_DELETE")) ) return [];
    return super._getCopyableObjects(options);
  }

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

  /** @override */
  storeHistory(type, data, options) {

    // Don't store stop/pause/resume/clear movement updates in the history
    if ( options._stopMovement || options._pauseMovement || options._resumeMovement
      || options._clearMovementHistory ) return;

    const undoOptions = {};
    if ( type === "update" ) {

      // Clean actorData and delta updates from the history so changes to those fields are not undone.
      for ( const d of data ) {
        delete d.actorData;
        delete d.delta;
        delete d._movementHistory;
        delete d._regions;
      }

      // Undo movement
      if ( options._movement ) {
        for ( const d of data ) {
          const movement = options._movement[d._id];
          if ( !movement?.passed.waypoints.length ) continue;
          for ( const key of TokenDocument.MOVEMENT_FIELDS ) delete d[key];

          // Force teleport the token back to the origin
          undoOptions.movement ??= {};
          undoOptions.movement[d._id] = {waypoints: [{...movement.origin, action: "displace"}], method: "undo"};
          undoOptions.diff = false;
          undoOptions.animate = false;

          // Undo movement history
          if ( movement.recorded ) {
            d._movementHistory = movement.history.recorded.waypoints.map(
              waypoint => ({...waypoint, cost: waypoint.cost === Infinity ? null : waypoint.cost}));
          }
        }
      }
    }

    this._storeHistory(type, data, undoOptions);
  }

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

  /** @override */
  _onCycleViewKey(event) {
    if ( !ui.controls.tool?.control || canvas.controls.ruler.active ) return false;
    if ( this._draggedToken ) this.#cycleDragMovementAction(event.shiftKey);
    else {
      const cycled = this.cycleTokens(!event.shiftKey, false);
      if ( !cycled ) canvas.recenter();
    }
    return true;
  }

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

  /** @override */
  async _confirmDeleteKey(documents) {
    const warnings = [];
    if ( documents.some(t => t.attachments.regions.size) ) warnings.push("TOKEN.DeleteAttachedRegionWarning");
    let inCombat = false;
    const tokens = new Set(documents);
    for ( const combat of game.combats ) {
      for ( const combatant of combat.combatants ) {
        if ( tokens.has(combatant.token) ) {
          inCombat = true;
          break;
        }
      }
      if ( inCombat ) break;
    }
    if ( inCombat ) warnings.push("TOKEN.DeleteCombatantWarning");
    if ( !warnings.length ) return true;
    return DialogV2.confirm({
      window: {
        icon: "fa-solid fa-trash",
        title: _loc("DOCUMENT.Delete", {type: _loc(TokenDocument.metadata.label)})
      },
      content: `<p><strong>${_loc("COMMON.AreYouSure")}</strong></p>${warnings.map(w => `<p>${_loc(w)}</p>`)}`
    });
  }

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

  /** @override */
  static prepareSceneControls() {
    const sc = SceneControls;
    return {
      name: "tokens",
      order: 1,
      title: "CONTROLS.GroupToken",
      icon: "fa-solid fa-user-large",
      onChange: (event, active) => {
        if ( active ) canvas.tokens.activate();
      },
      onToolChange: (_event, tool, active) => {
        canvas.tokens.setAllRenderFlags({refreshState: true});
        if ( active && !tool.control ) canvas.tokens.releaseAll();
      },
      tools: {
        select: {
          name: "select",
          order: 1,
          title: "CONTROLS.BasicSelect",
          icon: "fa-solid fa-expand",
          interaction: true,
          control: true,
          toolclip: {
            src: "toolclips/tools/token-select.webm",
            heading: "CONTROLS.BasicSelect",
            items: sc.buildToolclipItems([
              {paragraph: "CONTROLS.BasicSelectP"},
              "selectAlt", "selectMultiple", "move", "rotate", "hud", "sheet",
              game.user.isGM ? "editAlt" : null,
              game.user.isGM ? "delete" : null,
              {heading: "CONTROLS.RulerPlaceWaypoint", reference: "CONTROLS.CtrlClick"},
              {heading: "CONTROLS.RulerRemoveWaypoint", reference: "CONTROLS.RightClick"}
            ])
          },
          onChange: (_event, _active) => {
            canvas.notes.setAllRenderFlags({refreshState: true});
          }
        },
        target: {
          name: "target",
          order: 2,
          title: "CONTROLS.TargetSelect",
          icon: "fa-solid fa-bullseye",
          interaction: true,
          control: true,
          toolclip: {
            src: "toolclips/tools/token-target.webm",
            heading: "CONTROLS.TargetSelect",
            items: sc.buildToolclipItems([
              {paragraph: "CONTROLS.TargetSelectP"}, "selectAlt", "selectMultiple"
            ])
          }
        },
        ruler: {
          name: "ruler",
          order: 3,
          title: "CONTROLS.BasicMeasure",
          icon: "fa-solid fa-ruler",
          control: true,
          toolclip: {
            heading: "CONTROLS.BasicMeasure",
            items: sc.buildToolclipItems([
              {heading: "CONTROLS.RulerPlaceWaypoint", reference: "CONTROLS.CtrlClick"},
              {heading: "CONTROLS.RulerRemoveWaypoint", reference: "CONTROLS.RightClick"}
            ])
          }
        },
        unconstrainedMovement: {
          name: "unconstrainedMovement",
          order: 4,
          title: "CONTROLS.UnconstrainedMovement",
          icon: "fa-solid fa-ghost",
          toggle: true,
          active: game.settings.get("core", "unconstrainedMovement"),
          visible: game.user.isGM,
          onChange: (event, toggled) => {
            game.settings.set("core", "unconstrainedMovement", toggled);
          },
          toolclip: {
            heading: "CONTROLS.UnconstrainedMovement",
            items: sc.buildToolclipItems([
              {paragraph: "CONTROLS.UnconstrainedMovementP"}
            ])
          }
        }
      },
      activeTool: "select"
    };
  }

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

  /** @inheritDoc */
  _highlightObjects(active) {
    super._highlightObjects(active);
    canvas.perception.update({
      refreshOcclusion: this.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HIGHLIGHTED,
      refreshOccludedSurfaces: true
    });
  }

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

  /**
   * Cycle the drag movement action override.
   * @param {boolean} reverse
   */
  #cycleDragMovementAction(reverse) {
    const token = this._draggedToken;
    let actions;
    let current;
    if ( (this._movementPlanningContext?.object === token)
      && this._movementPlanningContext.allowedActions ) {
      actions = this._movementPlanningContext.allowedActions;
      current = this._dragMovementAction ?? actions[0];
    } else {
      const currentAction = token.movementAction;
      actions = Object.entries(CONFIG.Token.movement.actions).reduce((actions, [action, {canSelect}]) => {
        if ( canSelect(token.document) || (action === currentAction) ) actions.push(action);
        return actions;
      }, []);
      current = this._dragMovementAction ?? token._getDragMovementAction();
    }
    const currentIndex = actions.indexOf(current);
    if ( currentIndex < 0 ) this._dragMovementAction = actions[0];
    else {
      const nextIndex = (currentIndex + (reverse ? -1 : 1) + actions.length) % actions.length;
      this._dragMovementAction = actions[nextIndex];
    }
    this.recalculatePlannedMovementPaths();
  }

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

  /**
   * Place Tokens at the cursor.
   * Each Token is placed one after the other in the given order.
   * The placed Tokens can be rotated with the mouse wheel unless the `allowRotation` is false.
   * @param {Iterable<Partial<TokenData>>} data  The data of the Tokens to place
   * @param {object} [options]               Additional options
   * @param {boolean} [options.create=true]  Create the Tokens? If false, the preview documents is returned.
   *                                         Default: `true`. Non-GMs cannot create Tokens while the game is paused.
   * @param {Partial<Omit<DatabaseCreateOperation, "parent">>} [options.createOptions]  Optional creation options.
   *   By default the creation option `controlObject` is true.
   * @param {boolean} [options.allowRotation=true]  Allow rotation of the Tokens? Default: `true`.
   * @param {(args: {event: PIXI.FederatedEvent; preview: Token; document: TokenDocument; index: number; count: number;
   *     position: Point; snap: boolean}) => boolean|void} [options.onMove]
   *   Called when the pointer is moved and after starting the placement of the next Token on confirm and skip. This
   *   callback replaces the default behavior if false is returned. If false is returned, the callback should modify
   *   the passed `document` and set the render flags on `preview` corresponding to the applied changes.
   * @param {(args: {event: WheelEvent; preview: Token; document: TokenDocument; index: number; count: number;
   *     precise: boolean}) => boolean|void} [options.onRotate]
   *   Called when the mouse wheel is scrolled. This callback replaces the default behavior if false is returned.
   *   If false is returned, the callback should modify the `document` and set the render flags on `preview`
   *   corresponding to the applied changes.
   * @param {(args: {preview: Token; document: TokenDocument; index: number; count: number}) => void} [options.onChange]
   *   Called when the position or rotation of the Token that is placed has changed.
   * @param {(args: {event: PIXI.FederatedEvent; document: TokenDocument; index: number;
   *     count: number}) => boolean|void} [options.preConfirm]
   *   Called before the confirmation (left-click) of a Token placement. This callback may return false to prevent
   *   the placement of the Token and display a warning.
   * @param {(args: {event: PIXI.FederatedEvent; document: TokenDocument; index: number;
   *     count: number}) => boolean|void} [options.preSkip]
   *   Called before skipping (right-click) of a Token placement. This callback may return false to prevent
   *   skipping of the Token and display a warning.
   * @param {(documents: ReadonlyArray<TokenDocument>) => Promise<any|void>|void} [options.preCommit]
   *   Called at the end of the workflow before the Token documents are created/returned. This callback may return
   *   a falsely value other than undefined to prevent the Tokens from being created/returned.
   * @returns {Promise<TokenDocument[]>} The Token documents that were placed and not rejected by preCreate.
   *   If the dismiss key was pressed, the placement was rejected by `preCommit`, or the game was paused, the user is
   *   not a GM, and the `create` option is true, and empty array is returned.
   * @example Place 3 tokens with random actor.
   * ```js
   * const {count: numTokensToSpawn=3} = await foundry.applications.api.DialogV2.input({
   *  window: {
   *     title: "How many tokens to you want to place?"
   *  },
   *  content: `<input type="number" name="count" min="0" step="1" value="3">`
   * }) ?? {};
   * const actors = game.actors.contents;
   * const tokensToPlace = [];
   * for ( let i = 0; i < numTokensToSpawn; i++ ) {
   *   const actor = actors[Math.floor(Math.random() * actors.length)];
   *   const token = await actor.getTokenDocument({level: canvas.level.id}, {parent: canvas.scene});
   *   tokensToPlace.push(token.toObject());
   * }
   * const placedTokens = await canvas.tokens.placeTokens(tokensToPlace);
   * ```
   */
  async placeTokens(data, {create=true, createOptions={}, allowRotation=true, onMove, onRotate, onChange, preConfirm,
    preSkip, preCommit}={}) {
    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 documents
    const documents = Array.from(data, data => {
      data = foundry.utils.deepClone(data);

      // Multiple previews need a unique PlaceableObject#objectId and #sourceId
      data._id ||= foundry.utils.randomID();
      return new CONFIG.Token.documentClass(data, {parent: canvas.scene});
    });
    if ( !documents.length ) throw new Error("There must be at least one Token to be placed.");
    if ( documents.some(d => !d.isOwner ) ) throw new Error("You must be an owner of the Tokens in order to place them.");

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

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

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

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

    // Activate token layer
    this.activate({tool: "select"});

    // Create preview objects
    const previews = documents.map(document => {
      const preview = new CONFIG.Token.objectClass(document);
      preview._previewType = "creation";
      document._object = preview;
      return preview;
    });

    // Create placement context
    const {promise, resolve, reject} = Promise.withResolvers();
    this._placementContext = {data, previews, placed: [], index: 0, resolve, reject, create,
      createOptions: foundry.utils.deepClone(createOptions), allowRotation, onMove,
      onRotate, onChange, preConfirm, preSkip, preCommit, rotationNotification: false};

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

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

    return promise;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle dropping of ActiveEffect data onto a Token, creating a new ActiveEffect on the corresponding Actor.
   * @param {DragEvent} event
   * @param {{type: "ActiveEffect"; uuid: string; x: number; y: number}} data
   * @internal
   */
  async _onDropActiveEffect(event, data) {
    if ( !this.active ) return;
    const effect = await ActiveEffect.implementation.fromDropData(data);
    if ( !effect ) return;
    const collisionTest = ({t: token}) => token.visible && token.renderable && token.interactive
      && token.hitArea?.contains(data.x - token.x, data.y - token.y);
    const target = Array.from(this.quadtree.getObjects(new PIXI.Rectangle(data.x, data.y, 0, 0), {collisionTest}))
      .sort((a, b) => a._lastSortedIndex - b._lastSortedIndex).at(0);
    const actor = target?.actor;
    if ( !actor ) return;
    await ActiveEffect.implementation.create(effect.toObject(), {parent: actor});
  }

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

  /**
   * Handle dropping of Actor data onto the Scene canvas
   * @param {DragEvent} event
   * @param {{type: "Actor"; uuid: string; x: number; y: number; elevation?: number}} data
   * @internal
   */
  async _onDropActorData(event, data) {

    // Ensure the user has permission to drop the actor and create a Token
    if ( !game.user.can("TOKEN_CREATE") ) {
      return ui.notifications.warn("You do not have permission to create new Tokens!");
    }

    // Validate the drop position
    if ( !canvas.dimensions.rect.contains(data.x, data.y) ) return false;

    // Acquire dropped data and import the actor
    let actor = await Actor.implementation.fromDropData(data);
    if ( !actor.isOwner ) {
      return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`);
    }
    if ( actor.inCompendium ) {
      const actorData = game.actors.fromCompendium(actor);
      actor = await Actor.implementation.create(actorData, {fromCompendium: true});
    }

    // Prepare the Token document
    const token = await actor.getTokenDocument({
      hidden: game.user.isGM && event.altKey,
      sort: Math.max(this.getMaxSort() + 1, 0)
    }, {parent: canvas.scene});

    // Set the position of the Token such that its center point is the drop position before snapping
    const position = CONFIG.Token.objectClass._getDropActorPosition(token, {x: data.x, y: data.y,
      elevation: data.elevation}, {snap: !event.shiftKey});
    token.updateSource(position);

    // Submit the Token creation request and activate the Tokens layer (if not already active)
    this.activate();
    return token.constructor.create(token, {parent: canvas.scene});
  }

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

  /** @inheritDoc */
  _onClickLeft(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickLeft(event);
    let tool = game.activeTool;

    // If Control is being held, we always want the Tool to be Ruler
    const ruler = canvas.controls.ruler;
    if ( ruler.active ) return ruler._onClickLeft(event);
    if ( CONFIG.Canvas.rulerClass.canMeasure ) tool = "ruler";

    // Tool-specific handling
    switch ( tool ) {
      case "target":
        // Clear targets if Left Click Release is set
        if ( game.settings.get("core", "leftClickRelease") ) {
          game.user._onUpdateTokenTargets([]);
          game.user.broadcastActivity({targets: []});
        }
        break;

      // Prevent default behavior with the Ruler tool
      case "ruler": return;
    }

    // If we don't explicitly return from handling the tool, use the default behavior
    super._onClickLeft(event);
  }

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

  /** @inheritDoc */
  _onClickLeft2(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickLeft2(event);

    // Otherwise handle the event normally
    super._onClickLeft2(event);
  }

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

  /** @inheritDoc */
  _onClickRight(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickRight(event);

    // Otherwise handle the event normally
    super._onClickRight(event);
  }

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

  /** @inheritDoc */
  _onClickRight2(event) {

    // If a token is being dragged, delegate to this token
    if ( this._draggedToken ) return this._draggedToken._onDragClickRight2(event);

    // Otherwise handle the event normally
    super._onClickRight2(event);
  }

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

  /** @override */
  _onDragLeftCancel(event) {
    // Override so that previews are not cleared
  }

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

  /** @override */
  _onMouseWheel(event) {
    if ( this._placementContext ) {
      const {previews, index, allowRotation, onRotate, onChange} = this._placementContext;
      if ( !allowRotation ) {
        if ( this._placementContext.rotationNotification ) return;
        ui.notifications.warn("TOKEN.NOTIFICATIONS.PlacedTokenRotationNotAllowed", {localize: true});
        this._placementContext.rotationNotification = true;
        return;
      }
      const preview = previews[index];
      const document = preview.document;
      const count = previews.length;
      const precise = !event.shiftKey;
      const {x, y, elevation, width, height, depth, shape, level, rotation} = document._source;
      const prior = {x, y, elevation, width, height, depth, shape, level, rotation};
      if ( !onRotate || (onRotate({event, preview, document, index, count, precise}) !== false) ) {
        const interval = canvas.grid.isHexagonal ? (precise ? 30 : 60) : (precise ? 15 : 45);
        const delta = interval * Math.sign(event.delta);
        document.updateSource({rotation: (document.rotation + delta).toNearest(interval)});
        preview.renderFlags.set({refreshRotation: true});
      }
      if ( onChange && (!TokenDocument.arePositionsEqual(document._source, prior)
        || (document._source.rotation !== prior.rotation)) ) onChange({preview, document, index, count});
      return;
    }

    if ( canvas.regions._placementContext ) {
      canvas.regions._onMouseWheel(event);
      return;
    }

    if ( this._draggedToken ) return this._draggedToken._onDragMouseWheel(event);

    if ( !this.controlled.length ) return;

    // Prevent wheel rotation during dragging
    if ( this.preview.children.length ) return;

    // Determine the incremental angle of rotation from event data
    const snap = canvas.grid.isHexagonal ? (event.shiftKey ? 60 : 30) : (event.shiftKey ? 45 : 15);
    const delta = snap * Math.sign(event.delta);
    return this._throttleRotateMany({delta, snap});
  }

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

  /** @inheritDoc */
  _onDismissKey(event) {
    if ( this._placementContext ) {
      this._cancelPlacement();
      return true;
    }
    if ( canvas.regions._placementContext ) {
      canvas.regions._cancelPlacement();
      return true;
    }
    const consumed = super._onDismissKey(event);
    if ( consumed ) return true;
    if ( this._movementPlanningContext ) {
      this._cancelMovementPlanning();
      return true;
    }
    return false;
  }

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

  /**
   * Called when the placement is moved.
   * @param {PIXI.FederatedEvent} event    The pointer event
   */
  #movePlacement(event) {
    const {previews, index, onMove, onChange} = this._placementContext;
    const preview = previews[index];
    const document = preview.document;
    const count = previews.length;
    if ( !preview.parent ) {
      this.preview.addChild(preview);
      preview.draw();
    }
    const position = event.data.getLocalPosition(this);
    const snap = !event.shiftKey;
    const {x, y, elevation, width, height, depth, shape, level, rotation} = document._source;
    const prior = {x, y, elevation, width, height, depth, shape, level, rotation};
    if ( !onMove || (onMove({event, preview, document, index, count, position, snap}) !== false) ) {
      const pivot = document.getCenterPoint({x: 0, y: 0});
      let changes = {x: position.x - pivot.x, y: position.y - pivot.y};
      if ( snap ) changes = document.getSnappedPosition(changes);
      document.updateSource(changes);
      preview.renderFlags.set({refreshPosition: true});
    }
    if ( onChange && (!TokenDocument.arePositionsEqual(document._source, prior)
      || (document._source.rotation !== prior.rotation)) ) onChange({preview, document, index, count});
  }

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

  /**
   * Confirm the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  async #confirmPlacement(event) {
    const {previews, placed, index, preConfirm} = this._placementContext;
    const preview = previews[index];
    const document = preview.document;
    const count = previews.length;
    if ( preConfirm ) {
      const allowed = preConfirm({event, document, index, count});
      if ( allowed === false ) return;
    }
    placed.push(document);
    this.#nextPlacement(event);
  }

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

  /**
   * Skip the placement.
   * @param {PIXI.FederatedEvent} event    The pointerdown event
   */
  #skipPlacement(event) {
    const {previews, index, preSkip} = this._placementContext;
    const preview = previews[index];
    const document = preview.document;
    const count = previews.length;
    if ( preSkip ) {
      const allowed = preSkip({event, document, index, count});
      if ( allowed === false ) return;
    }
    if ( !preview.destroyed ) preview.destroy({children: true});
    this.#nextPlacement(event);
  }

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

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

    // If not all tokens have been placed yet, begin placement of the next token
    if ( index < previews.length - 1 ) {
      this._placementContext.index = index = index + 1;
      this.#movePlacement(event);
      return;
    }

    // Otherwise finish placement and create or return the token documents
    const {preCommit, placed, resolve, reject, create, createOptions} = this._placementContext;
    this.#finishPlacement();
    let tokens;
    try {
      let allowed = true;
      if ( preCommit ) {
        allowed = await preCommit(Object.freeze(placed));
        if ( allowed === undefined ) allowed = true;
      }
      if ( !allowed ) tokens = [];
      else if ( create && placed.length ) {
        const scene = placed[0].parent;
        tokens = await scene.createEmbeddedDocuments("Token", placed,
          {controlObject: true, ...createOptions, parent: scene});
      } else {
        tokens = placed;
        for ( const token of tokens ) {
          token._object = null;
          token._destroyed = false;
        }
      }
      resolve(tokens);
    } catch(err) {
      reject(err);
    } finally {
      for ( const preview of previews ) {
        if ( !preview.destroyed ) preview.destroy({children: true});
      }
    }
  }

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

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

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

  /**
   * Reset the placement context and unregister all its event listeners.
   */
  #finishPlacement() {
    canvas.stage.off("pointermove", this.#placementPointerMoveListener);
    canvas.stage.off("pointerdown", this.#placementPointerDownListener);
    this._placementContext = null;
    this.setAllRenderFlags({refreshState: true});
  }

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

  /**
   * Cancel movement planning.
   * @internal
   */
  _cancelMovementPlanning() {
    if ( !this._movementPlanningContext ) return;
    const {object, resolve} = this._movementPlanningContext;
    this._movementPlanningContext = null;
    this.setAllRenderFlags({refreshState: true});
    if ( this._draggedToken === object ) {
      const manager = this._draggedToken.mouseInteractionManager;
      manager.interactionData.cancelled = true;
      manager.cancel();
    }
    resolve(null);
  }
}
