import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "@common/constants.mjs";
import DialogV2 from "../../applications/api/dialog.mjs";
import * as fields from "@common/data/fields.mjs";

/**
 * @import {ElevatedPoint} from "../../_types.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 * @import {RegionTokenExitEvent, RegionTokenMoveInEvent, TokenPosition} from "@client/documents/_types.mjs";
 * @import {Level, User} from "@client/documents/_module.mjs";
 * @import RegionDocument from "@client/documents/region.mjs";
 */

/**
 * The data model for a behavior that prompts to change the level of Tokens that enter the Region.
 */
export default class ChangeLevelRegionBehaviorType extends RegionBehaviorType {

  /** @override */
  static LOCALIZATION_PREFIXES = ["BEHAVIOR.TYPES.changeLevel", "BEHAVIOR.TYPES.base"];

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

  /** @override */
  static defineSchema() {
    return {
      movementActions: new fields.SetField(new fields.StringField({required: true, blank: false, nullable: false,
        initial: undefined, choices: () => {
          const actions = {...CONFIG.Token.movement.actions};
          delete actions.displace;
          return actions;
        }}))
    };
  }

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

  /** @inheritDoc */
  _initializeSource(data, options) {
    if ( Array.isArray(data.movementActions) ) {
      for ( let i = data.movementActions.length - 1; i >= 0; i-- ) {
        const action = data.movementActions[i];
        if ( (typeof action === "string") && !(action in CONFIG.Token.movement.actions) ) {
          data.movementActions.splice(i, 1);
        }
      }
    }
    return super._initializeSource(data, options);
  }

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

  /**
   * Prompt to change the level of the Token if it moves into the Region.
   * @param {RegionTokenMoveInEvent} event
   * @this {ChangeLevelRegionBehaviorType}
   */
  static async #onTokenMoveIn(event) {
    const user = event.user;
    if ( !user.isSelf ) return;

    // Displacement does not trigger level change
    const movement = event.data.movement;
    if ( movement.passed.waypoints.at(-1).action === "displace" ) return;

    // If the region doesn't span multiple levels, there's nothing to do
    const token = event.data.token;
    const originLevelId = token._source.level;
    const destinationLevels = ChangeLevelRegionBehaviorType.#getDestinationLevels(this.region, token);
    if ( !destinationLevels.length ) return;

    // If the user cannot use any of the allowed movement actions, there's nothing to do
    const selectableActions = ChangeLevelRegionBehaviorType.#getSelectableMovementActions(token, this.movementActions);
    if ( !selectableActions.size ) return;

    // Pause movement while the user decides whether to change the level
    const resumeMovement = token.pauseMovement();

    // When the browser tab is/becomes hidden, don't wait for the movement animation and
    // proceed immediately. Otherwise wait for the movement animation to complete.
    if ( token.rendered && token.object.movementAnimationPromise ) {
      await game.raceWithWindowHidden(token.object.movementAnimationPromise);
    }

    // Confirm the level change
    let currentAction = movement.passed.waypoints.at(-1).action;
    const {level: destinationLevelId, action: selectedAction} = await this.#confirmDialog(destinationLevels, token,
      currentAction, selectableActions) ?? {};
    if ( !destinationLevelId || (destinationLevelId === originLevelId) ) {
      resumeMovement?.();
      return;
    }
    const destinationLevel = token.parent.levels.get(destinationLevelId);
    if ( !destinationLevel ) return;

    // Move the token to the destination level unless the token has exited in the meantime
    if ( !token.regions.has(this.region) ) return;
    let snap;
    if ( token.movement.id === movement.id ) {
      snap = movement.pending.waypoints.at(0)?.snapped ?? movement.passed.waypoints.at(-1).snapped;
    } else {
      currentAction = token.movementAction;
      const snapped = token.getSnappedPosition(token._source);
      snap = (token._source.x === Math.round(snapped.x)) && (token._source.y === Math.round(snapped.y))
        && token._source.elevation.almostEqual(snapped.elevation);
    }
    await this.#moveToken(token, destinationLevel, currentAction, selectedAction, snap);

    // The view isn't automatically changed for GM users
    if ( game.user.isGM && token.parent.isView && (canvas.level.id === originLevelId) ) {
      await token.parent.view({level: destinationLevelId, controlledTokens: [token.id]});
    }
  }

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

  /**
   * Disable the confirmation dialog.
   * @param {RegionTokenExitEvent} event
   * @this {ChangeLevelRegionBehaviorType}
   */
  static async #onTokenExit(event) {
    const {token} = event.data;
    const dialog = foundry.applications.instances.get(this.#getConfirmDialogId(token));
    if ( dialog ) this.#disableConfirmDialog(dialog, token);
  }

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

  /**
   * Move the token to the given.
   * @param {TokenDocument} token
   * @param {Level} destinationLevel
   * @param {string} currentAction
   * @param {string} selectedAction
   * @param {boolean} snap
   */
  async #moveToken(token, destinationLevel, currentAction, selectedAction, snap) {

    // First move the token such that its origin is inside the region
    const waypoints = [];
    const closestPosition = this.#getClosestOriginContainingPosition(token);
    if ( closestPosition ) waypoints.push({...closestPosition, action: currentAction});

    // Then snap the token if the original movement was snapped
    if ( snap && !token.parent.grid.isGridless ) {
      const snappedPosition = this.#getSnappedPosition(token, waypoints.at(-1) ?? token._source);
      if ( snappedPosition ) waypoints.push({...snappedPosition, action: currentAction});
    }

    // Try to move the token into position before changing the level.
    // Note that we might move the token out of the region here if this movement is not fully contained
    // within the region and is constrained outside of the region. This is probably unlikely to occur in
    // practice though.
    if ( waypoints.length ) {
      const movementId = foundry.utils.randomID();
      const originLevelId = token._source.level;
      await token.move(waypoints, {id: movementId});
      if ( (token.movement.id !== movementId) || (token._source.level !== originLevelId) ) return;
    }

    // Move the token to the destination level. Ignore surfaces.
    await token.move({elevation: destinationLevel.elevation.base, level: destinationLevel.id, action: selectedAction},
      {animate: false, constrainOptions: {ignoreWalls: true}, split: !!waypoints.length});
  }

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

  /**
   * Find the closest position, in which the token's origin is contained within the region.
   * @param {TokenDocument} token
   * @returns {TokenPosition|void}
   */
  #getClosestOriginContainingPosition(token) {
    const {x, y, elevation, width, height, depth, shape, level} = token._source;
    const center = token.getMovementOrigin(token._source);
    const closest = this.region.polygonTree.findClosestPoint(center);
    const x0 = Math.round(closest.x - (center.x - x));
    const y0 = Math.round(closest.y - (center.y - y));
    const position = {x: x0, y: y0, elevation, width, height, depth, shape, level};
    for ( const dx of [0, 1, -1] ) {
      position.x = x0 + dx;
      for ( const dy of [0, 1, -1] ) {
        position.y = y0 + dy;
        if ( token.testInsideRegion(this.region, position)
          && this.region.polygonTree.testPoint(token.getMovementOrigin(position), -0.75) ) {
          return position;
        }
      }
    }
  }

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

  /**
   * Get the snapped position that is contained within the region.
   * @param {TokenDocument} token
   * @param {TokenPosition} position
   * @returns {TokenPosition|void}
   */
  #getSnappedPosition(token, position) {
    const {x, y, elevation, width, height, depth, shape, level} = position;
    const snappedPosition = {x, y, elevation, width, height, depth, shape, level};
    for ( const dx of [0, 1, -1] ) {
      for ( const dy of [0, 1, -1] ) {
        const snapped = token.getSnappedPosition({x: x + dx, y: y + dy, width, height, shape});
        snappedPosition.x = Math.round(snapped.x);
        snappedPosition.y = Math.round(snapped.y);
        if ( token.testInsideRegion(this.region, snappedPosition)
          && this.region.polygonTree.testPoint(token.getMovementOrigin(snappedPosition), -0.75) ) {
          return snappedPosition;
        }
      }
    }
  }

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

  /** @override */
  static events = {
    [REGION_EVENTS.TOKEN_MOVE_IN]: this.#onTokenMoveIn,
    [REGION_EVENTS.TOKEN_EXIT]: this.#onTokenExit
  };

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

  /**
   * Get the possible destination levels.
   * @param {RegionDocument} region      The region.
   * @param {TokenDocument} token        The token that was moved.
   * @returns {Level[]}
   */
  static #getDestinationLevels(region, token) {
    let levels;
    if ( !region.levels.size ) {
      levels = region.parent.levels.reverseSorted.filter(l => l.id !== token._source.level);
    } else {
      levels = region._source.levels.reduce((arr, id) => {
        const level = region.parent.levels.get(id);
        if ( level && (id !== token._source.level) ) arr.push(level);
        return arr;
      }, []);
      levels.sort((a, b) => b.index - a.index);
    }
    return levels;
  }

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

  /**
   * Get the selectable movement actions.
   * @param {TokenDocument} token         The token that was moved.
   * @param {Set<string>} allowedActions  The allowed movement actions to change the level.
   * @returns {Set<string>}               The selectable movement actions.
   */
  static #getSelectableMovementActions(token, allowedActions) {
    const selectableActions = new Set();
    for ( const action of (allowedActions.size ? allowedActions
      : foundry.utils.objectKeys(CONFIG.Token.movement.actions)) ) {
      const config = CONFIG.Token.movement.actions[action];
      if ( (action !== token.movementAction) && !config.canSelect(token) ) continue;
      selectableActions.add(action);
    }
    return selectableActions;
  }

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

  /**
   * Display a dialog to confirm the level change.
   * @param {Level[]} levels                The destination levels.
   * @param {TokenDocument} token           The token that was moved.
   * @param {string} currentAction          The current movement action of the token.
   * @param {Set<string>} selectableActions The selectable movement actions to change the level.
   * @returns {Promise<{level: string; action: string}|null>}     The result of the dialog.
   */
  async #confirmDialog(levels, token, currentAction, selectableActions) {
    const questionElement = window.document.createElement("p");
    questionElement.textContent = levels.length !== 1 ? _loc("BEHAVIOR.TYPES.changeLevel.ConfirmSelect", {token: token.name})
      : _loc("BEHAVIOR.TYPES.changeLevel.Confirm", {token: token.name, level: levels[0].name});
    const levelSelect = foundry.applications.fields.createFormGroup({
      label: _loc("BEHAVIOR.TYPES.changeLevel.Level"),
      input: foundry.applications.fields.createSelectInput({
        name: "level",
        options: levels.map(level => ({value: level.id, label: level.name})),
        disabled: levels.length <= 1
      }),
      classes: levels.length === 1 ? ["hidden"] : []
    });
    const actionSelect = foundry.applications.fields.createFormGroup({
      label: _loc("BEHAVIOR.TYPES.changeLevel.MovementAction"),
      input: foundry.applications.fields.createSelectInput({
        name: "action",
        options: selectableActions.reduce((options, action) => {
          options.push({value: action, label: _loc(CONFIG.Token.movement.actions[action].label)})
          return options;
        }, []),
        value: [currentAction, token.movementAction].find(action => selectableActions.has(action))
          ?? selectableActions.first(),
        disabled: selectableActions.size <= 1
      })
    });
    const content = window.document.createElement("div");
    content.append(questionElement, levelSelect, actionSelect);
    const getConfirmLabel = destinationLevel => {
      const originLevel = token.parent.levels.get(token._source.level);
      if ( !originLevel ) return "BEHAVIOR.TYPES.changeLevel.Move";
      if ( originLevel.elevation.base > destinationLevel.elevation.base ) return "BEHAVIOR.TYPES.changeLevel.MoveDown";
      if ( originLevel.elevation.base < destinationLevel.elevation.base ) return "BEHAVIOR.TYPES.changeLevel.MoveUp";
      return "BEHAVIOR.TYPES.changeLevel.Move";
    };
    return DialogV2.confirm({
      id: this.#getConfirmDialogId(token),
      window: {
        icon: CONFIG.RegionBehavior.typeIcons.changeLevel,
        title: CONFIG.RegionBehavior.typeLabels.changeLevel
      },
      content,
      yes: {
        label: getConfirmLabel(levels[0]),
        callback: (event, button) => ({
          level: button.form.elements.level.value,
          action: button.form.elements.action.value
        })
      },
      no: {label: "BEHAVIOR.TYPES.changeLevel.DoNotMove"},
      render: (event, dialog) => {
        dialog.element.querySelector('[name="level"]').addEventListener("change", e => {
          const span = dialog.element.querySelector('[data-action="yes"] > span');
          span.textContent = _loc(getConfirmLabel(token.parent.levels.get(e.target.value)));
        });
        if ( !token.regions.has(this.region) ) this.#disableConfirmDialog(dialog, token);
      }
    });
  }

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

  /**
   * Get the confirmation dialog ID for this token.
   * @param {TokenDocument} token
   * @returns {string}
   */
  #getConfirmDialogId(token) {
    return `dialog-${this.behavior.uuid}-${token.id}`;
  }

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

  /**
   * Disable the confirmation dialog.
   * @param {DialogV2} dialog
   * @param {TokenDocument} token
   */
  #disableConfirmDialog(dialog, token) {
    const tooltip = _loc("BEHAVIOR.TYPES.changeLevel.ConfirmDisabled", {token: token.name});
    const yesButtonOptions = dialog.options.buttons.yes;
    yesButtonOptions.disabled = true;
    yesButtonOptions.tooltip = tooltip;
    if ( dialog.rendered ) {
      const yesButton = dialog.element.querySelector('button[data-action="yes"]');
      yesButton.disabled = true;
      yesButton.dataset.tooltip = "";
      yesButton.ariaLabel = tooltip;
    }
    else dialog.render();
  }
}
