import RegionBehaviorType from "./base.mjs";
import {REGION_EVENTS} from "@common/constants.mjs";
import * as fields from "@common/data/fields.mjs";
import DialogV2 from "../../applications/api/dialog.mjs";
import {fromUuid, fromUuidSync} from "@client/utils/helpers.mjs";
import RegionDocument from "@client/documents/region.mjs";

/**
 * @import {ElevatedPoint} from "../../_types.mjs";
 * @import TokenDocument from "@client/documents/token.mjs";
 * @import {RegionTokenMoveInEvent} from "@client/documents/_types.mjs";
 * @import {User} from "@client/documents/_module.mjs";
 */

/**
 * The data model for a behavior that teleports Token that enter the Region to a preset destination Region.
 *
 * @property {Set<RegionDocument>} destination         The destination(s) Region the Token is teleported to.
 * @property {"random"|"center"|"relative"} placement  The placement of the Token within the destination Region.
 * @property {boolean} snap                            Snap the Token destination position?
 * @property {boolean} choice                          Show teleportation confirmation dialog?
 * @property {boolean} revealed                        Are the destinations revealed?
 * @property {{revealed: string|null; unrevealed: string|null}} dialog  Custom teleporation dialogs
 * @property {{type: string|null; duration: number}} transition         Transition options
 */
export default class TeleportTokenRegionBehaviorType extends RegionBehaviorType {

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

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

  /** @override */
  static defineSchema() {
    const transitionType = new fields.StringField({required: true, nullable: true, blank: false, initial: null});
    transitionType.toInput = function(config={}) {
      config.blank ??= _loc("COMMON.None");
      config.choices ??= Object.entries(CONFIG.Canvas.sceneTransitions).reduce((types, [key, value]) => {
        types[key] = _loc(value.label ?? `SCENE.Transition.Types.${key}`);
        return types;
      }, {});
      return Object.getPrototypeOf(this).toInput.call(this, config);
    };
    return {
      destinations: new fields.SetField(new fields.DocumentUUIDField({type: "Region", nullable: false, relative: true})),
      placement: new fields.StringField({required: true, initial: "random", choices: {
        random: "BEHAVIOR.TYPES.teleportToken.PLACEMENTS.random.label",
        center: "BEHAVIOR.TYPES.teleportToken.PLACEMENTS.center.label",
        relative: "BEHAVIOR.TYPES.teleportToken.PLACEMENTS.relative.label"
      }}),
      snap: new fields.BooleanField({initial: true}),
      choice: new fields.BooleanField(),
      revealed: new fields.BooleanField(),
      dialog: new fields.SchemaField({
        revealed: new fields.StringField({required: true, nullable: true, blank: false}),
        unrevealed: new fields.StringField({required: true, nullable: true, blank: false})
      }),
      transition: new fields.SchemaField({
        type: transitionType,
        duration: new fields.NumberField({required: true, nullable: false, integer: true,
          initial: 1500, min: 500, max: 10000, step: 100})
      })
    };
  }

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

  /**
   * Teleport the Token if it moves into the Region.
   * @param {RegionTokenMoveInEvent} event
   * @this {TeleportTokenRegionBehaviorType}
   */
  static async #onTokenMoveIn(event) {
    const movement = event.data.movement;

    // Displacement does not trigger teleportation
    if ( movement.passed.waypoints.at(-1).action === "displace" ) return;

    // Get destination regions
    const destinations = [];
    for ( const regionUuid of this.destinations ) {
      const destination = fromUuidSync(regionUuid, {relative: this.parent});
      if ( !(destination instanceof RegionDocument) ) {
        console.error(`${regionUuid} does not exist`);
        continue;
      }
      destinations.push(destination);
    }
    if ( destinations.length === 0 ) return;

    const token = event.data.token;
    const user = event.user;
    if ( user.isSelf ) {
      if ( this.choice ) token.pauseMovement(this.parent.uuid);
      else token.stopMovement();
    }
    if ( !this.#shouldTeleport(token, destinations, user) ) return;
    const originLevel = token._source.level;

    // 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 destination if choice
    let result;
    if ( this.choice ) {
      if ( user.isSelf ) result = await this.#confirmDialog(destinations, token);
      else result = await user.query("confirmTeleportToken", {behaviorUuid: this.parent.uuid, tokenUuid: token.uuid});
      if ( !result ) {
        token.resumeMovement(movement.id, this.parent.uuid);
        return;
      }
    }

    // Select the destination
    let destination;
    if ( typeof result === "string" ) destination = destinations.find(d => d.uuid === result);
    else destination = destinations[Math.floor(Math.random() * destinations.length)];
    if ( !destination ) {
      if ( this.choice ) token.resumeMovement(movement.id, this.parent.uuid);
      return;
    }
    if ( !token.regions.has(this.region) ) return;

    // Calculate of offset for relative placement
    const center = token.getCenterPoint(event.data.movement.destination);
    const offset = {
      x: (center.x - this.region.bounds.left) / this.region.bounds.width,
      y: (center.y - this.region.bounds.top) / this.region.bounds.height
    };

    // Teleport token to destination
    const panOptions = this.transition.type ? {transitionType: this.transition.type, duration: this.transition.duration,
      force: true} : {duration: 0, force: true};
    const destinationToken = await destination.teleportToken(token, {placement: this.placement, snap: this.snap, offset,
      pan: panOptions});
    const destinationLevel = destinationToken._source.level;
    if ( (token.parent === destination.parent) && (originLevel === destinationLevel) ) return;

    // Only control the teleported token in the destination scene and override transition animation
    const viewOptions = {level: destinationLevel, controlledTokens: [destinationToken.id], transition: this.transition};

    // Pull the user that moved the token and all active non-GM users that don't own a token in this scene (anymore)
    // and observe the teleported token
    if ( game.user.isGM ) {
      if ( token.parent !== destination.parent ) {
        destination.parent.pullUsers(this.#getUsersToPull(token, user, game.user), viewOptions);
      } else if ( !user.isSelf && !token.hidden && (token.parent.id === user.viewedScene)
        && (originLevel === user.viewedLevel) ) {
        destination.parent.pullUsers([user], viewOptions);
      }
    }

    // If this user moved the token, view the destination level
    if ( user.isSelf && token.parent.isView && (canvas.level.id === originLevel) ) {
      await destination.parent.view(viewOptions);
    }
  }

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

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

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

  /** @override */
  _handleRegionEvent(event) {
    if ( event.name !== REGION_EVENTS.TOKEN_EXIT ) return;
    const {token} = event.data;
    const dialog = foundry.applications.instances.get(this.#getConfirmDialogId(token));
    if ( dialog ) this.#disableConfirmDialog(dialog, token);
  }

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

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    if ( this.choice ) this.events.add(REGION_EVENTS.TOKEN_EXIT);
  }

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

  /**
   * Should the current user teleport the token?
   * @param {TokenDocument} token           The token that is teleported.
   * @param {RegionDocument[]} destinations The destination regions.
   * @param {User} user                     The user that moved the token.
   * @returns {boolean}                     Should the current user teleport the token?
   */
  #shouldTeleport(token, destinations, user) {
    const userCanTeleport = user.isGM || destinations.every(d => token.parent === d.parent)
      || (user.can("TOKEN_CREATE") && user.can("TOKEN_DELETE") && (!game.users.activeGM || !this.#getUsersToPull(token, user, user).length));
    if ( userCanTeleport ) return user.isSelf;
    return game.user.isDesignated(u => u.active && u.can("TOKEN_CREATE") && u.can("TOKEN_DELETE") && (!this.choice || u.can("QUERY_USER")));
  }

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

  /**
   * Get the Users that need to be pulled to the destination Scene.
   * @param {TokenDocument} token      The token that is teleported.
   * @param {User} user                The user that moved the token into the teleporter.
   * @param {User} designatedUser      The user designated to teleport the token.
   * @returns {User[]}
   */
  #getUsersToPull(token, user, designatedUser) {
    if ( token.hidden ) return [];
    const scene = token.parent;
    const toPull = [];
    for ( const u of game.users ) {

      // Don't pull inactive users, the designated user, and GMs
      if ( !u.active || (u === designatedUser) || u.isGM ) continue;

      // Don't pull users that don't view the level the token is in
      if ( (scene.id !== u.viewedScene) || (token._source.level !== u.viewedLevel) ) continue;

      // The user that moved the token is always pulled if they view the level the token is in
      if ( u !== user ) {

        // Don't pull users that don't observe the token
        if ( !token.testUserPermission(u, "OBSERVER") ) continue;

        // If the user still owns a nonhidden token with vision in the scene, don't pull them
        if ( scene.tokens.some(t => (t !== token) && !t.hidden && t.testUserPermission(u, "OWNER")
          && (t._source.level === token._source.level) && (!scene.tokenVision || t.sight.enabled)) ) continue;
      }

      toPull.push(u);
    }
    return toPull;
  }

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

  /**
   * The query handler for teleporation confirmation.
   * @type {(queryData: {behaviorUuid: string; tokenUuid: string}) => Promise<boolean>}
   * @internal
   */
  static _confirmQuery = async ({behaviorUuid, tokenUuid}) => {
    const behavior = await fromUuid(behaviorUuid);
    if ( !behavior || (behavior.type !== "teleportToken") || !behavior.system.destinations.size ) return false;
    const destinations = Array.from(behavior.system.destinations, fromUuidSync)
      .filter(d => d instanceof RegionDocument);
    if ( destinations.length === 0 ) return false;
    const token = await fromUuid(tokenUuid);
    if ( !token ) return false;
    return behavior.system.#confirmDialog(destinations, token);
  };

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

  /**
   * Display a dialog to confirm the teleportation?
   * @param {RegionDocument} destinations   The destination regions.
   * @param {TokenDocument} token           The token that is teleported.
   * @returns {Promise<boolean|string>}     The result of the dialog.
   */
  async #confirmDialog(destinations, token) {
    const config = {
      id: this.#getConfirmDialogId(token),
      window: {
        icon: CONFIG.RegionBehavior.typeIcons.teleportToken,
        title: CONFIG.RegionBehavior.typeLabels.teleportToken
      },
      yes: {label: "BEHAVIOR.TYPES.teleportToken.Teleport"},
      no: {label: "BEHAVIOR.TYPES.teleportToken.DoNotTeleport"},
      render: (event, dialog) => {
        if ( !token.regions.has(this.region) ) this.#disableConfirmDialog(dialog, token);
      }
    };
    if ( !this.revealed && !game.user.isGM ) {
      const text = this.dialog.unrevealed ? foundry.utils.escapeHTML(this.dialog.unrevealed) : "BEHAVIOR.TYPES.teleportToken.Confirm";
      const textReplacements = {token: foundry.utils.escapeHTML(token.name)};
      config.content = `<p>${_loc(text, textReplacements)}</p>`;
    } else if ( destinations.length === 1 ) {
      const destination = destinations[0];
      const text = this.dialog.revealed ? foundry.utils.escapeHTML(this.dialog.revealed) : "BEHAVIOR.TYPES.teleportToken.ConfirmRevealed";
      const textReplacements = {
        token: foundry.utils.escapeHTML(token.name),
        region: foundry.utils.escapeHTML(destination.name),
        scene: foundry.utils.escapeHTML(destination.parent.navName || destination.parent.name)
      };
      config.content = `<p>${_loc(text, textReplacements)}</p>`;
    } else {
      const scene = destinations[0].parent;
      const allInSameScene = destinations.every(d => d.parent === scene);
      const options = destinations.map(d => ({value: d.uuid, label: allInSameScene ? d.name : _loc("BEHAVIOR.TYPES.teleportToken.ConfirmSelectOption", {
        region: foundry.utils.escapeHTML(d.name),
        scene: foundry.utils.escapeHTML(d.parent.navName || d.parent.name)
      })}))
        .sort((a, b) => a.label.localeCompare(b.label));
      const text = this.dialog.revealed ? foundry.utils.escapeHTML(this.dialog.revealed) : `BEHAVIOR.TYPES.teleportToken.ConfirmSelect${allInSameScene ? "Same" : "Different"}Scene`;
      const textReplacements = {token: foundry.utils.escapeHTML(token.name)};
      if ( allInSameScene ) textReplacements.scene = foundry.utils.escapeHTML(scene.navName || scene.name);
      config.content = `
        <p>${_loc(text, textReplacements)}</p>
        <select name="destination">
          ${options.map(({value, label}) => `<option value="${value}">${label}</option>`).join("")}
        </select>
      `;
      config.yes.callback = (event, button) => button.form.elements.destination.value;
    }
    return DialogV2.confirm(config);
  }

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

  /**
   * 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.teleportToken.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();
  }

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

  /** @inheritDoc */
  static migrateData(data, options) {
    /** @deprecated since v14 */
    if ( !("destinations" in data) ) {
      if ( data.destination === null ) data.destinations = [];
      else if ( typeof data.destination === "string" ) data.destinations = [data.destination];
      delete data.destination;
    }
    return super.migrateData(data, options);
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  get destination() {
    foundry.utils.logCompatibilityWarning("You are accessing TeleportTokenRegionBehaviorType#destination which has been"
      + " migrated to TeleportTokenRegionBehaviorType#destinations", {since: 14, until: 16, once: true});
    return this.destinations.first() ?? null;
  }
}
