import { showArtSelect } from '../token-variants.mjs';
import { SEARCH_TYPE, getFileName, isVideo, keyPressed, mergeMappings } from '../scripts/utils.js';
import TokenCustomConfig from './tokenCustomConfig.js';
import { TVA_CONFIG, getFlagMappings, migrateMappings, updateSettings } from '../scripts/settings.js';
import EditJsonConfig from './configJsonEdit.js';
import EditScriptConfig from './configScriptEdit.js';
import { OverlayConfig } from './overlayConfig.js';
import { showMappingSelectDialog, showOverlayJsonConfigDialog, showTokenCaptureDialog } from './dialogs.js';
import { DEFAULT_ACTIVE_EFFECT_CONFIG } from '../scripts/models.js';
import { updateWithEffectMapping } from '../scripts/hooks/effectMappingHooks.js';
import { drawOverlays } from '../scripts/token/overlay.js';
import { Templates } from './templates.js';

// Persist group toggles across forms
let TOGGLED_GROUPS;
const NO_IMAGE = 'modules\\token-variants\\img\\empty.webp';

export default class EffectMappingForm extends FormApplication {
  constructor(token, { globalMappings = false, callback = null, createMapping = null } = {}) {
    super({}, {});

    this.token = token;
    if (globalMappings) {
      this.globalMappings = deepClone(TVA_CONFIG.globalMappings).filter(Boolean);
    }
    if (!globalMappings) this.objectToFlag = game.actors.get(token.actorId);
    this.callback = callback;
    TOGGLED_GROUPS = game.settings.get('token-variants', 'effectMappingToggleGroups') || {
      Default: true,
    };
    this.createMapping = createMapping;
  }

  static get defaultOptions() {
    return mergeObject(super.defaultOptions, {
      id: 'token-variants-active-effect-config',
      classes: ['sheet'],
      template: 'modules/token-variants/templates/effectMappingForm.html',
      resizable: true,
      minimizable: false,
      closeOnSubmit: false,
      width: 1020,
      height: 'auto',
      scrollY: ['ol.token-variant-table'],
    });
  }

  get title() {
    let scope = 'GLOBAL';
    if (!this.globalMappings) scope = this.objectToFlag.name;
    return `[${scope}] Mappings`;
  }

  _processConfig(mapping) {
    if (!mapping.config) mapping.config = {};
    let hasTokenConfig = Object.keys(mapping.config).filter((k) => mapping.config[k]).length;
    if (mapping.config.flags) hasTokenConfig--;
    if (mapping.config.tv_script) hasTokenConfig--;

    return {
      id: mapping.id || randomID(8),
      label: mapping.label,
      expression: mapping.expression,
      codeExp: mapping.codeExp,
      hasCodeExp: Boolean(mapping.codeExp),
      highlightedExpression: highlightOperators(mapping.expression),
      imgName: mapping.imgName,
      imgSrc: mapping.imgSrc,
      isVideo: mapping.imgSrc ? isVideo(mapping.imgSrc) : false,
      priority: mapping.priority,
      hasConfig: mapping.config ? !isEmpty(mapping.config) : false,
      hasScript: mapping.config && mapping.config.tv_script,
      hasTokenConfig: hasTokenConfig > 0,
      config: mapping.config,
      overlay: mapping.overlay,
      alwaysOn: mapping.alwaysOn,
      tokens: mapping.tokens,
      tokensString: mapping.tokens?.join(',') ?? '',
      tokenIDs: mapping.tokens?.length
        ? 'Assigned Tokens\n' + mapping.tokens.join('\n') + '\n\n[CLICK TO UNASSIGN]'
        : '',
      disabled: mapping.disabled,
      overlayConfig: mapping.overlayConfig,
      targetActors: mapping.targetActors,
      group: mapping.group,
      parentID: mapping.overlayConfig?.parentID,
    };
  }

  async getData(options) {
    const data = super.getData(options);

    data.NO_IMAGE = NO_IMAGE;

    let mappings = [];
    if (this.object.mappings) {
      mappings = this.object.mappings.map(this._processConfig);
    } else {
      const effectMappings = this.globalMappings ?? getFlagMappings(this.objectToFlag);
      mappings = effectMappings.map(this._processConfig);

      if (this.createMapping && !effectMappings.find((m) => m.expression === this.createMapping.expression)) {
        mappings.push(this._processConfig(this._getNewEffectConfig(this.createMapping)));
      }
      this.createMapping = null;
    }

    mappings = mappings.sort((m1, m2) => {
      if (!m1.label && m2.label) return -1;
      else if (m1.label && !m2.label) return 1;

      if (!m1.overlayConfig?.parentID && m2.overlayConfig?.parentID) return -1;
      else if (m1.overlayConfig?.parentID && !m2.overlayConfig?.parentID) return 1;

      let priorityDiff = m1.priority - m2.priority;
      if (priorityDiff === 0) return m1.label.localeCompare(m2.label);
      return priorityDiff;
    });

    const [sMappings, groupedMappings] = sortMappingsToGroups(mappings);
    data.groups = Object.keys(groupedMappings);

    this.object.mappings = sMappings;
    data.groupedMappings = groupedMappings;
    data.global = Boolean(this.globalMappings);
    return data;
  }

  /**
   * @param {JQuery} html
   */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('.delete-mapping').click(this._onRemove.bind(this));
    html.find('.clone-mapping').click(this._onClone.bind(this));
    html.find('.create-mapping').click(this._onCreate.bind(this));
    html.find('.save-mappings').click(this._onSaveMappings.bind(this));
    html.find('.save-mappings-close').click(this._onSaveMappingsClose.bind(this));
    if (TVA_CONFIG.permissions.image_path_button[game.user.role]) {
      html.find('.mapping-image img').click(this._onImageClick.bind(this));
      html.find('.mapping-image img').mousedown(this._onImageMouseDown.bind(this));
      html.find('.mapping-image video').click(this._onImageClick.bind(this));
      html.find('.mapping-target').click(this._onConfigureApplicableActors.bind(this));
    }
    html.find('.mapping-image img').contextmenu(this._onImageRightClick.bind(this));
    html.find('.mapping-image video').contextmenu(this._onImageRightClick.bind(this));
    html.find('.mapping-config i.config').click(this._onConfigClick.bind(this));
    html.find('.mapping-config i.config-edit').click(this._onConfigEditClick.bind(this));
    html.find('.mapping-config i.config-script').click(this._onConfigScriptClick.bind(this));
    html.find('.mapping-overlay i.overlay-config').click(this._onOverlayConfigClick.bind(this));
    html.on('contextmenu', '.mapping-overlay i.overlay-config', this._onOverlayConfigRightClick.bind(this));
    html.find('.mapping-overlay input').on('change', this._onOverlayChange).trigger('change');
    html.find('.div-input').on('input paste focus', this._onExpressionChange);
    const app = this;
    html
      .find('.group-toggle > a')
      .on('click', this._onGroupToggle.bind(this))
      .each(function () {
        const group = $(this).closest('.group-toggle');
        const groupName = group.data('group');
        if (!TOGGLED_GROUPS[groupName]) {
          $(this).trigger('click');
        }
      });
    this.setPosition({ width: 1020 });
    html.find('.mapping-disable > input').on('change', this._onDisable.bind(this));
    html.find('.group-disable > a').on('click', this._onGroupDisable.bind(this));
    html.find('.group-delete').on('click', this._onGroupDelete.bind(this));
    html.find('.mapping-group > input').on('change', this._onGroupChange.bind(this));
    html.find('.expression-switch').on('click', this._onExpressionSwitch.bind(this));
    html
      .find('.expression-code textarea')
      .focus((event) => $(event.target).animate({ height: '10em' }, 500, () => this.setPosition()))
      .focusout((event) =>
        $(event.target).animate({ height: '1em' }, 500, () => {
          if (this._state === Application.RENDER_STATES.RENDERED) this.setPosition();
        })
      );
    html.find('.tokens').on('click', this._onTokensRemove.bind(this));
  }

  async _onTokensRemove(event) {
    await this._onSubmit(event);

    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];
    mapping.tokens = undefined;

    this.render();
  }

  _onExpressionSwitch(event) {
    const container = $(event.target).closest('.expression-container');
    const divInput = container.find('.div-input');
    const codeExp = container.find('.expression-code');

    if (codeExp.hasClass('hidden')) {
      codeExp.removeClass('hidden');
      divInput.addClass('hidden');
    } else {
      codeExp.addClass('hidden');
      divInput.removeClass('hidden');
    }
  }

  async _onDisable(event) {
    const groupName = $(event.target).closest('.table-row').data('group');
    const disableGroupToggle = $(event.target)
      .closest('.token-variant-table')
      .find(`.group-disable[data-group="${groupName}"]`);

    const checkboxes = $(event.target)
      .closest('.token-variant-table')
      .find(`[data-group="${groupName}"] > .mapping-disable`);
    const numChecked = checkboxes.find('input:checked').length;

    if (checkboxes.length !== numChecked) {
      disableGroupToggle.addClass('active');
    } else disableGroupToggle.removeClass('active');
  }

  async _onGroupDisable(event) {
    const group = $(event.target).closest('.group-disable');
    const groupName = group.data('group');
    const chks = $(event.target).closest('form').find(`[data-group="${groupName}"]`).find('.mapping-disable > input');

    if (group.hasClass('active')) {
      group.removeClass('active');
      chks.prop('checked', true);
    } else {
      group.addClass('active');
      chks.prop('checked', false);
    }
  }

  async _onGroupDelete(event) {
    const group = $(event.target).closest('.group-delete');
    const groupName = group.data('group');
    await this._onSubmit(event);
    this.object.mappings = this.object.mappings.filter((m) => m.group !== groupName);
    this.render();
  }

  async _onGroupChange(event) {
    const input = $(event.target);
    let group = input.val().trim();
    if (!group) group = 'Default';
    input.val(group);

    await this._onSubmit(event);
    this.render();
  }

  _onGroupToggle(event) {
    const group = $(event.target).closest('.group-toggle');
    const groupName = group.data('group');
    const form = $(event.target).closest('form');
    form.find(`li[data-group="${groupName}"]`).toggle();
    if (group.hasClass('active')) {
      group.removeClass('active');
      group.find('i').addClass('fa-rotate-180');
      TOGGLED_GROUPS[groupName] = false;
    } else {
      group.addClass('active');
      group.find('i').removeClass('fa-rotate-180');
      TOGGLED_GROUPS[groupName] = true;
    }
    game.settings.set('token-variants', 'effectMappingToggleGroups', TOGGLED_GROUPS);
    this.setPosition({ height: 'auto' });
  }

  async _onExpressionChange(event) {
    var el = event.target;

    // Update the hidden input field so that the text entered in the div will be submitted via the form
    $(el).siblings('input').val(event.target.innerText);

    // The rest of the function is to handle operator highlighting and management of the caret position

    if (!el.childNodes.length) return;

    // Calculate the true/total caret offset within the div
    const sel = window.getSelection();
    const focusNode = sel.focusNode;
    let offset = sel.focusOffset;

    for (const ch of el.childNodes) {
      if (ch === focusNode || ch.childNodes[0] === focusNode) break;
      offset += ch.nodeName === 'SPAN' ? ch.innerText.length : ch.length;
    }

    // Highlight the operators and update the div
    let text = highlightOperators(event.target.innerText);
    $(event.target).html(text);

    // Set the new caret position with the div
    setCaretPosition(el, offset);
  }

  async _onOverlayChange(event) {
    if (event.target.checked) {
      $(event.target).siblings('a').show();
    } else {
      $(event.target).siblings('a').hide();
    }
  }

  async _onOverlayConfigClick(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];

    new OverlayConfig(
      mapping.overlayConfig,
      (config) => {
        mapping.overlayConfig = config;
        const gear = $(li).find('.mapping-overlay > a');
        if (config?.parentID && config.parentID !== 'TOKEN') {
          gear.addClass('child');
          gear.attr('title', 'Child Of: ' + config.parentID);
        } else {
          gear.removeClass('child');
          gear.attr('title', '');
        }
      },
      this.token,
      { mapping, global: Boolean(this.globalMappings), actor: this.objectToFlag }
    ).render(true);
  }

  async _onOverlayConfigRightClick(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];
    showOverlayJsonConfigDialog(mapping.overlayConfig, (config) => (mapping.overlayConfig = config));
  }

  async _toggleActiveControls(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];
    const tokenConfig = $(event.target).closest('.mapping-config').find('.config');
    const configEdit = $(event.target).closest('.mapping-config').find('.config-edit');
    const scriptEdit = $(event.target).closest('.mapping-config').find('.config-script');

    let hasTokenConfig = Object.keys(mapping.config).filter((k) => mapping.config[k]).length;
    if (mapping.config.flags) hasTokenConfig--;
    if (mapping.config.tv_script) hasTokenConfig--;

    if (hasTokenConfig) tokenConfig.addClass('active');
    else tokenConfig.removeClass('active');

    if (Object.keys(mapping.config).filter((k) => mapping.config[k]).length) configEdit.addClass('active');
    else configEdit.removeClass('active');

    if (mapping.config.tv_script) scriptEdit.addClass('active');
    else scriptEdit.removeClass('active');
  }

  async _onConfigScriptClick(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];

    new EditScriptConfig(mapping.config?.tv_script, (script) => {
      if (!mapping.config) mapping.config = {};
      if (script) mapping.config.tv_script = script;
      else delete mapping.config.tv_script;
      this._toggleActiveControls(event);
    }).render(true);
  }

  async _onConfigEditClick(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];

    new EditJsonConfig(mapping.config, (config) => {
      mapping.config = config;
      this._toggleActiveControls(event);
    }).render(true);
  }

  async _onConfigClick(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];
    new TokenCustomConfig(
      this.token,
      {},
      null,
      null,
      (config) => {
        if (!config || isEmpty(config)) {
          config = {};
          config.tv_script = mapping.config.tv_script;
          config.flags = mapping.config.flags;
        }
        mapping.config = config;
        this._toggleActiveControls(event);
      },
      mapping.config ? mapping.config : {}
    ).render(true);
  }

  _removeImage(event) {
    const vid = $(event.target).closest('.mapping-image').find('video');
    const img = $(event.target).closest('.mapping-image').find('img');
    vid.add(img).attr('src', '').attr('title', '');
    vid.hide();
    img.show();
    img.attr('src', NO_IMAGE);
    $(event.target).siblings('.imgSrc').val('');
    $(event.target).siblings('.imgName').val('');
  }

  async _onImageMouseDown(event) {
    if (event.which === 2) {
      this._removeImage(event);
    }
  }

  async _onImageClick(event) {
    if (keyPressed('config')) {
      this._removeImage(event);
      return;
    }

    let search = this.token.name;
    if (search === 'Unknown') {
      const li = event.currentTarget.closest('.table-row');
      const mapping = this.object.mappings[li.dataset.index];
      search = mapping.label;
    }

    showArtSelect(search, {
      searchType: SEARCH_TYPE.TOKEN,
      callback: (imgSrc, imgName) => {
        const vid = $(event.target).closest('.mapping-image').find('video');
        const img = $(event.target).closest('.mapping-image').find('img');
        vid.add(img).attr('src', imgSrc).attr('title', imgName);
        if (isVideo(imgSrc)) {
          vid.show();
          img.hide();
        } else {
          vid.hide();
          img.show();
        }
        $(event.target).siblings('.imgSrc').val(imgSrc);
        $(event.target).siblings('.imgName').val(imgName);
      },
    });
  }

  async _onImageRightClick(event) {
    if (keyPressed('config')) {
      this._removeImage(event);
      return;
    }

    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];

    new FilePicker({
      type: 'imagevideo',
      current: mapping.imgSrc,
      callback: (path) => {
        const vid = $(event.target).closest('.mapping-image').find('video');
        const img = $(event.target).closest('.mapping-image').find('img');
        vid.add(img).attr('src', path).attr('title', getFileName(path));
        if (isVideo(path)) {
          vid.show();
          img.hide();
        } else {
          vid.hide();
          img.show();
        }
        $(event.target).siblings('.imgSrc').val(path);
        $(event.target).siblings('.imgName').val(getFileName(path));
      },
    }).render();
  }

  async _onRemove(event) {
    event.preventDefault();
    await this._onSubmit(event);
    const li = event.currentTarget.closest('.table-row');
    this.object.mappings.splice(li.dataset.index, 1);
    this.render();
  }

  async _onClone(event) {
    event.preventDefault();
    await this._onSubmit(event);
    const li = event.currentTarget.closest('.table-row');
    const clone = deepClone(this.object.mappings[li.dataset.index]);
    clone.label = clone.label + ' - Copy';
    clone.id = randomID(8);
    this.object.mappings.push(clone);
    this.render();
  }

  async _onCreate(event) {
    event.preventDefault();
    await this._onSubmit(event);
    this.object.mappings.push(this._getNewEffectConfig());
    this.render();
  }

  _getNewEffectConfig({ label = '', expression = '' } = {}) {
    // if (textOverlay) {
    //   TOGGLED_GROUPS['Text Overlays'] = true;
    //   return {
    //     id: randomID(8),
    //     label: label,
    //     expression: label,
    //     highlightedExpression: highlightOperators(label),
    //     imgName: '',
    //     imgSrc: '',
    //     priority: 50,
    //     overlay: false,
    //     alwaysOn: false,
    //     disabled: false,
    //     group: 'Text Overlays',
    //     overlay: true,
    //     overlayConfig: mergeObject(
    //       DEFAULT_OVERLAY_CONFIG,
    //       {
    //         img: '',
    //         linkScale: false,
    //         linkRotation: false,
    //         linkMirror: false,
    //         offsetY: 0.5 + Math.round(Math.random() * 0.3 * 100) / 100,
    //         offsetX: 0,
    //         scaleX: 0.68,
    //         scaleY: 0.68,
    //         text: {
    //           text: '{{effect}}',
    //           fontFamily: CONFIG.defaultFontFamily,
    //           fontSize: 36,
    //           fill: new Color(Math.round(Math.random() * 16777215)).toString(),
    //           stroke: '#000000',
    //           strokeThickness: 2,
    //           dropShadow: false,
    //           curve: {
    //             radius: 160,
    //             invert: false,
    //           },
    //         },
    //         animation: {
    //           rotate: true,
    //           duration: 10000 + Math.round(Math.random() * 14000) + 10000,
    //           clockwise: true,
    //         },
    //       },
    //       { inplace: false }
    //     ),
    //   };
    // } else {
    TOGGLED_GROUPS['Default'] = true;
    return mergeObject(deepClone(DEFAULT_ACTIVE_EFFECT_CONFIG), {
      label,
      expression,
      id: randomID(8),
    });
    // }
  }

  _getHeaderButtons() {
    const buttons = super._getHeaderButtons();

    buttons.unshift({
      label: 'Export',
      class: 'token-variants-export',
      icon: 'fas fa-file-export',
      onclick: (ev) => this._exportConfigs(ev),
    });
    buttons.unshift({
      label: 'Import',
      class: 'token-variants-import',
      icon: 'fas fa-file-import',
      onclick: (ev) => this._importConfigs(ev),
    });

    buttons.unshift({
      label: 'Templates',
      class: 'token-variants-templates',
      icon: 'fa-solid fa-book',
      onclick: async (ev) => {
        new Templates({
          mappings: this.globalMappings ?? getFlagMappings(this.objectToFlag),
          callback: (templateName, mappings) => {
            this._insertMappings(ev, mappings);
          },
        }).render(true);
      },
    });

    if (this.globalMappings) return buttons;

    buttons.unshift({
      label: 'Copy Global Config',
      class: 'token-variants-copy-global',
      icon: 'fas fa-globe',
      onclick: (ev) => this._copyGlobalConfig(ev),
    });
    buttons.unshift({
      label: 'Open Global',
      class: 'token-variants-open-global',
      icon: 'fas fa-globe',
      onclick: async (ev) => {
        await this.close();
        new EffectMappingForm(this.token, { globalMappings: true }).render(true);
      },
    });

    buttons.unshift({
      label: '',
      class: 'token-variants-print-token',
      icon: 'fa fa-print',
      onclick: () => showTokenCaptureDialog(canvas.tokens.get(this.token._id)),
    });
    return buttons;
  }

  async _exportConfigs(event) {
    let filename = '';

    let mappings = await new Promise((resolve) => {
      showMappingSelectDialog(this.globalMappings ?? getFlagMappings(this.objectToFlag), { callback: resolve });
    });

    if (mappings && !isEmpty(mappings)) {
      if (this.globalMappings) {
        filename = 'token-variants-global-mappings.json';
      } else {
        let actorName = this.objectToFlag.name ?? 'Actor';
        actorName = actorName.replace(/[/\\?%*:|"<>]/g, '-');
        filename = 'token-variants-' + actorName + '.json';
      }
      saveDataToFile(JSON.stringify({ globalMappings: mappings }, null, 2), 'text/json', filename);
    }
  }

  async _importConfigs(event) {
    const content = await renderTemplate('templates/apps/import-data.html', {
      entity: 'token-variants',
      name: 'settings',
    });
    let dialog = new Promise((resolve, reject) => {
      new Dialog(
        {
          title: 'Import Effect Configurations',
          content: content,
          buttons: {
            import: {
              icon: '<i class="fas fa-file-import"></i>',
              label: game.i18n.localize('token-variants.common.import'),
              callback: (html) => {
                const form = html.find('form')[0];
                if (!form.data.files.length) return ui.notifications?.error('You did not upload a data file!');
                readTextFromFile(form.data.files[0]).then((json) => {
                  json = JSON.parse(json);
                  if (!json || !('globalMappings' in json)) {
                    return ui.notifications?.error('No mappings found within the file!');
                  }

                  this._insertMappings(event, migrateMappings(json.globalMappings));
                  resolve(true);
                });
              },
            },
            no: {
              icon: '<i class="fas fa-times"></i>',
              label: 'Cancel',
              callback: (html) => resolve(false),
            },
          },
          default: 'import',
        },
        {
          width: 400,
        }
      ).render(true);
    });
    return await dialog;
  }

  _copyGlobalConfig(event) {
    showMappingSelectDialog(TVA_CONFIG.globalMappings, {
      title1: 'Global Mappings',
      title2: 'Select Mappings to Copy:',
      buttonTitle: 'Copy',
      callback: (mappings) => {
        this._insertMappings(event, mappings);
      },
    });
  }

  async _insertMappings(event, mappings) {
    const cMappings = deepClone(mappings).map(this._processConfig);
    await this._onSubmit(event);
    mergeMappings(cMappings, this.object.mappings);
    this.render();
  }

  _onConfigureApplicableActors(event) {
    const li = event.currentTarget.closest('.table-row');
    const mapping = this.object.mappings[li.dataset.index];

    let actorTypes = (game.system.entityTypes ?? game.system.documentTypes)['Actor'];
    let actors = [];
    for (const t of actorTypes) {
      const label = CONFIG['Actor']?.typeLabels?.[t] ?? t;
      actors.push({
        id: t,
        label: game.i18n.has(label) ? game.i18n.localize(label) : t,
        enabled: !mapping.targetActors || mapping.targetActors.includes(t),
      });
    }

    let content = '<form style="overflow-y: scroll; height:250x;">';
    for (const act of actors) {
      content += `
      <div class="form-group">
        <label>${act.label}</label>
        <div class="form-fields">
            <input type="checkbox" name="${act.id}" data-dtype="Boolean" ${act.enabled ? 'checked' : ''}>
        </div>
      </div>
      `;
    }
    content += `</form><div class="form-group"><button type="button" class="select-all">Select all</div>`;

    new Dialog({
      title: `Configure Applicable Actors`,
      content: content,
      buttons: {
        Ok: {
          label: `Save`,
          callback: async (html) => {
            let targetActors = [];
            html.find('input[type="checkbox"]').each(function () {
              if (this.checked) {
                targetActors.push(this.name);
              }
            });
            mapping.targetActors = targetActors;
          },
        },
      },
      render: (html) => {
        html.find('.select-all').click(() => {
          html.find('input[type="checkbox"]').prop('checked', true);
        });
      },
    }).render(true);
  }

  async _onSaveMappingsClose(event) {
    await this._onSaveMappings(event);
    this.close();
  }

  // TODO fix this spaghetti code related to globalMappings...
  async _onSaveMappings(event) {
    await this._onSubmit(event);
    if (this.objectToFlag || this.globalMappings) {
      // First filter out empty mappings
      let mappings = this.object.mappings;
      mappings = mappings.filter((m) => Boolean(m.label?.trim()) || Boolean(m.expression?.trim()));

      // Make sure a priority is assigned
      for (const mapping of mappings) {
        mapping.priority = mapping.priority ? mapping.priority : 50;
        mapping.overlayConfig = mapping.overlayConfig ?? {};
        mapping.overlayConfig.label = mapping.label;
      }

      if (mappings.length !== 0) {
        const effectMappings = mappings.map((m) =>
          mergeObject(DEFAULT_ACTIVE_EFFECT_CONFIG, m, {
            inplace: false,
            insertKeys: false,
            recursive: false,
          })
        );
        if (this.globalMappings) {
          updateSettings({ globalMappings: effectMappings });
        } else {
          await this.objectToFlag.setFlag('token-variants', 'effectMappings', effectMappings);
        }
      } else if (this.globalMappings) {
        updateSettings({ globalMappings: [] });
      } else {
        await this.objectToFlag.unsetFlag('token-variants', 'effectMappings');
      }

      const tokens = this.globalMappings ? canvas.tokens.placeables : this.objectToFlag.getActiveTokens();
      for (const tkn of tokens) {
        if (TVA_CONFIG.filterEffectIcons) {
          await tkn.drawEffects();
        }
        await updateWithEffectMapping(tkn);
        drawOverlays(tkn);
      }

      // Instruct users on other scenes to refresh the overlays
      const message = {
        handlerName: 'drawOverlays',
        args: { all: true, sceneId: canvas.scene.id },
        type: 'UPDATE',
      };
      game.socket?.emit('module.token-variants', message);
    }
    if (this.callback) this.callback();
  }

  /**
   * @param {Event} event
   * @param {Object} formData
   */
  async _updateObject(event, formData) {
    const mappings = expandObject(formData).mappings ?? {};

    // Merge form data with internal mappings
    for (let i = 0; i < this.object.mappings.length; i++) {
      const m1 = mappings[i];
      const m2 = this.object.mappings[i];
      m2.id = m1.id;
      m2.label = m1.label.replaceAll(String.fromCharCode(160), ' ');
      m2.expression = m1.expression.replaceAll(String.fromCharCode(160), ' ');
      m2.codeExp = m1.codeExp?.trim();
      m2.imgSrc = m1.imgSrc;
      m2.imgName = m1.imgName;
      m2.priority = m1.priority;
      m2.overlay = m1.overlay;
      m2.alwaysOn = m1.alwaysOn;
      m2.tokens = m1.tokens?.split(',');
      m2.disabled = m1.disabled;
      m2.group = m1.group;
    }
  }
}

// Insert <span/> around operators
function highlightOperators(text) {
  // text = text.replaceAll(' ', '&nbsp;');

  const re = new RegExp('([a-zA-Z\\.\\-\\|\\+]+)([><=]+)(".*?"|-?\\d+)(%{0,1})', `gi`);
  text = text.replace(re, function replace(match) {
    return '<span class="hp-expression">' + match + '</span>';
  });

  for (const op of ['\\(', '\\)', '&&', '||', '\\!', '\\*', '\\{', '\\}']) {
    text = text.replaceAll(op, `<span>${op}</span>`);
  }
  return text;
}

// Move caret to a specific point in a DOM element
function setCaretPosition(el, pos) {
  for (var node of el.childNodes) {
    // Check if it's a text node
    if (node.nodeType == 3) {
      if (node.length >= pos) {
        var range = document.createRange(),
          sel = window.getSelection();
        range.setStart(node, pos);
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);
        return -1; // We are done
      } else {
        pos -= node.length;
      }
    } else {
      pos = setCaretPosition(node, pos);
      if (pos == -1) {
        return -1; // No need to finish the for loop
      }
    }
  }
  return pos;
}

export function sortMappingsToGroups(mappings) {
  mappings.sort((m1, m2) => {
    if (!m1.label && m2.label) return -1;
    else if (m1.label && !m2.label) return 1;

    if (!m1.overlayConfig?.parentID && m2.overlayConfig?.parentID) return -1;
    else if (m1.overlayConfig?.parentID && !m2.overlayConfig?.parentID) return 1;

    let priorityDiff = m1.priority - m2.priority;
    if (priorityDiff === 0) return m1.label.localeCompare(m2.label);
    return priorityDiff;
  });

  let groupedMappings = { Default: { list: [], active: false } };
  mappings.forEach((mapping, index) => {
    mapping.i = index; // assign so that we can reference the mapping inside of an array
    if (!mapping.group || !mapping.group.trim()) mapping.group = 'Default';
    if (!(mapping.group in groupedMappings)) groupedMappings[mapping.group] = { list: [], active: false };
    if (!mapping.disabled) groupedMappings[mapping.group].active = true;
    groupedMappings[mapping.group].list.push(mapping);
  });
  return [mappings, groupedMappings];
}
