import { FEATURE_CONTROL, TVA_CONFIG, getFlagMappings, updateSettings } from '../settings.js';
import {
  applyCEEffect,
  applyTMFXPreset,
  determineAddedRemovedEffects,
  executeMacro,
  EXPRESSION_OPERATORS,
  getAllActorTokens,
  getFileName,
  mergeMappings,
  tv_executeScript,
  updateTokenImage,
} from '../utils.js';
import { broadcastDrawOverlays, drawOverlays } from '../token/overlay.js';
import { registerHook, unregisterHook } from './hooks.js';
import { CORE_TEMPLATES } from '../mappingTemplates.js';

const EXPRESSION_MATCH_RE = /(\\\()|(\\\))|(\|\|)|(\&\&)|(\\\!)/g;
const PF2E_ITEM_TYPES = ['condition', 'effect', 'weapon', 'equipment'];
const ITEM_TYPES = ['equipment', 'weapon'];
const feature_id = 'EffectMappings';

export function registerEffectMappingHooks() {
  if (!FEATURE_CONTROL[feature_id]) {
    [
      'canvasReady',
      'createActiveEffect',
      'deleteActiveEffect',
      'preUpdateActiveEffect',
      'updateActiveEffect',
      'createCombatant',
      'deleteCombatant',
      'preUpdateCombat',
      'updateCombat',
      'deleteCombat',
      'preUpdateToken',
      'preUpdateActor',
      'updateActor',
      'updateToken',
      'createToken',
      'preUpdateItem',
      'updateItem',
      'createItem',
      'deleteItem',
    ].forEach((name) => unregisterHook(feature_id, name));
    return;
  }

  if (game.user.isGM) {
    registerHook(feature_id, 'canvasReady', _refreshTokenMappings);
    _refreshTokenMappings();
  }

  registerHook(feature_id, 'createActiveEffect', (activeEffect, options, userId) => {
    if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
    const effectName = activeEffect.name ?? activeEffect.label;
    _updateImageOnEffectChange(effectName, activeEffect.parent, true);
  });
  registerHook(feature_id, 'deleteActiveEffect', (activeEffect, options, userId) => {
    if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
    const effectName = activeEffect.name ?? activeEffect.label;
    _updateImageOnEffectChange(effectName, activeEffect.parent, false);
  });
  registerHook(feature_id, 'preUpdateActiveEffect', _preUpdateActiveEffect);
  registerHook(feature_id, 'updateActiveEffect', _updateActiveEffect);
  registerHook(feature_id, 'preUpdateToken', _preUpdateToken);
  registerHook(feature_id, 'preUpdateActor', _preUpdateActor);
  registerHook(feature_id, 'updateActor', _updateActor);
  registerHook(feature_id, 'updateToken', _updateToken);
  registerHook(feature_id, 'createToken', _createToken);
  registerHook(feature_id, 'createCombatant', _createCombatant);
  registerHook(feature_id, 'deleteCombatant', (combatant, options, userId) => {
    if (game.userId !== userId) return;
    _deleteCombatant(combatant);
  });
  registerHook(feature_id, 'preUpdateCombat', _preUpdateCombat);
  registerHook(feature_id, 'updateCombat', _updateCombat);
  registerHook(feature_id, 'deleteCombat', (combat, options, userId) => {
    if (game.userId !== userId) return;
    combat.combatants.forEach((combatant) => {
      _deleteCombatant(combatant);
    });
  });

  const applicable_item_types = game.system.id === 'pf2e' ? PF2E_ITEM_TYPES : ITEM_TYPES;
  // Want to track condition/effect previous name so that the config can be reverted for it
  registerHook(feature_id, 'preUpdateItem', (item, change, options, userId) => {
    if (game.user.id === userId && applicable_item_types.includes(item.type)) {
      options['token-variants-old-name'] = item.name;
    }
    _preUpdateAssign(item.parent, change, options);
  });

  registerHook(feature_id, 'createItem', (item, options, userId) => {
    if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent) return;
    _updateImageOnEffectChange(item.name, item.parent, true);
  });

  registerHook(feature_id, 'deleteItem', (item, options, userId) => {
    if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent || item.disabled) return;
    _updateImageOnEffectChange(item.name, item.parent, false);
  });

  // Status Effects can be applied "stealthily" on item equip/un-equip
  registerHook(feature_id, 'updateItem', _updateItem);
}

async function _refreshTokenMappings() {
  for (const tkn of canvas.tokens.placeables) {
    await updateWithEffectMapping(tkn);
  }
}

function _createCombatant(combatant, options, userId) {
  if (game.userId !== userId) return;
  const token = combatant._token || canvas.tokens.get(combatant.tokenId);
  if (!token || !token.actor) return;

  updateWithEffectMapping(token, {
    added: ['token-variants-combat'],
  });
}

function _preUpdateActiveEffect(activeEffect, change, options, userId) {
  if (!activeEffect.parent || game.userId !== userId) return;

  if ('name' in change) {
    options['token-variants-old-name'] = activeEffect.name;
  }
}

function _updateActiveEffect(activeEffect, change, options, userId) {
  if (!activeEffect.parent || game.userId !== userId) return;

  const added = [];
  const removed = [];

  if ('disabled' in change) {
    if (change.disabled) removed.push(activeEffect.name);
    else added.push(activeEffect.name);
  }
  if ('name' in change) {
    removed.push(options['token-variants-old-name']);
    added.push(change.name);
  }

  if (added.length || removed.length) {
    _updateImageOnMultiEffectChange(activeEffect.parent, added, removed);
  }
}

function _preUpdateToken(token, change, options, userId) {
  if (game.user.id !== userId || change.actorId) return;

  const preUpdateEffects = getTokenEffects(token, true);
  if (preUpdateEffects.length) {
    setProperty(options, 'token-variants.preUpdateEffects', preUpdateEffects);
  }

  if (game.system.id === 'dnd5e' && token.actor?.isPolymorphed) {
    setProperty(options, 'token-variants.wasPolymorphed', true);
  }
}

async function _updateToken(token, change, options, userId) {
  if (game.user.id !== userId || change.actorId) return;

  // TODO
  token.object?.tvaOverlays?.forEach((ov) => ov.htmlOverlay?.render());

  const addedEffects = [];
  const removedEffects = [];
  const preUpdateEffects = getProperty(options, 'token-variants.preUpdateEffects') || [];
  const postUpdateEffects = getTokenEffects(token, true);
  determineAddedRemovedEffects(addedEffects, removedEffects, postUpdateEffects, preUpdateEffects);

  if (addedEffects.length || removedEffects.length || 'actorLink' in change) {
    updateWithEffectMapping(token, { added: addedEffects, removed: removedEffects });
  } else if (getProperty(options, 'token-variants.wasPolymorphed') && !token.actor?.isPolymorphed) {
    updateWithEffectMapping(token);
  }

  if (game.userId === userId && 'hidden' in change) {
    updateWithEffectMapping(token, {
      added: change.hidden ? ['token-variants-visibility'] : [],
      removed: !change.hidden ? ['token-variants-visibility'] : [],
    });
  }
}

function _preUpdateActor(actor, change, options, userId) {
  if (game.user.id !== userId) return;
  _preUpdateAssign(actor, change, options);
}

async function _updateActor(actor, change, options, userId) {
  if (game.user.id !== userId) return;

  if ('flags' in change && 'token-variants' in change.flags) {
    const tokenVariantFlags = change.flags['token-variants'];
    if ('effectMappings' in tokenVariantFlags || '-=effectMappings' in tokenVariantFlags) {
      const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
      tokens.forEach((tkn) => updateWithEffectMapping(tkn));
      for (const tkn of tokens) {
        if (tkn.object && TVA_CONFIG.filterEffectIcons) {
          await tkn.object.drawEffects();
        }
      }
    }
  }

  _preUpdateCheck(actor, options);
}

function _preUpdateAssign(actor, change, options) {
  if (!actor) return;

  // Determine which comparators are applicable so that we can compare after the
  // actor update
  const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  if (TVA_CONFIG.internalEffects.hpChange.enabled && tokens.length) {
    applyHpChangeEffect(actor, change, tokens);
  }
  for (const tkn of tokens) {
    const preUpdateEffects = getTokenEffects(tkn, true);

    if (preUpdateEffects.length) {
      setProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects', preUpdateEffects);
    }
  }
}

function _preUpdateCheck(actor, options, pAdded = [], pRemoved = []) {
  if (!actor) return;
  const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  for (const tkn of tokens) {
    // Check if effects changed by comparing them against the ones calculated in preUpdate*
    const added = [...pAdded];
    const removed = [...pRemoved];
    const postUpdateEffects = getTokenEffects(tkn, true);
    const preUpdateEffects = getProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects') ?? [];

    determineAddedRemovedEffects(added, removed, postUpdateEffects, preUpdateEffects);
    if (added.length || removed.length) updateWithEffectMapping(tkn, { added, removed });
  }
}

function _createToken(token, options, userId) {
  if (userId && userId === game.user.id) updateWithEffectMapping(token);
}

function _preUpdateCombat(combat, round, options, userId) {
  if (game.userId !== userId) return;
  options['token-variants'] = {
    combatantId: combat?.combatant?.token?.id,
    nextCombatantId: combat?.nextCombatant?.token?.id,
  };
}

function _updateCombat(combat, round, options, userId) {
  if (game.userId !== userId) return;

  const previousCombatantId = options['token-variants']?.combatantId;
  const previousNextCombatantId = options['token-variants']?.nextCombatantId;

  const currentCombatantId = combat?.combatant?.token?.id;
  const currentNextCombatantId = combat?.nextCombatant?.token?.id;

  const updateCombatant = function (id, added = [], removed = []) {
    if (game.user.isGM) {
      const token = canvas.tokens.get(id);
      if (token) updateWithEffectMapping(token, { added, removed });
    } else {
      const message = {
        handlerName: 'effectMappings',
        args: { tokenId: id, sceneId: canvas.scene.id, added, removed },
        type: 'UPDATE',
      };
      game.socket?.emit('module.token-variants', message);
    }
  };

  if (previousCombatantId !== currentCombatantId) {
    if (previousCombatantId) updateCombatant(previousCombatantId, [], ['combat-turn']);
    if (currentCombatantId) updateCombatant(currentCombatantId, ['combat-turn'], []);
  }
  if (previousNextCombatantId !== currentNextCombatantId) {
    if (previousNextCombatantId) updateCombatant(previousNextCombatantId, [], ['combat-turn-next']);
    if (currentNextCombatantId) updateCombatant(currentNextCombatantId, ['combat-turn-next'], []);
  }
}

function _updateItem(item, change, options, userId) {
  const added = [];
  const removed = [];

  if (game.user.id === userId) {
    // Handle condition/effect name change
    if (options['token-variants-old-name'] != null && options['token-variants-old-name'] !== item.name) {
      added.push(item.name);
      removed.push(options['token-variants-old-name']);
    }

    _preUpdateCheck(item.parent, options, added, removed);
  }
}

let EFFECT_M_QUEUES = {};
let EFFECT_M_TIMER;

export async function updateWithEffectMapping(token, { added = [], removed = [] } = {}) {
  const callUpdateWithEffectMapping = function () {
    for (const id of Object.keys(EFFECT_M_QUEUES)) {
      const m = EFFECT_M_QUEUES[id];
      _updateWithEffectMapping(m.token, m.opts.added, m.opts.removed);
    }
    EFFECT_M_QUEUES = {};
  };

  clearTimeout(EFFECT_M_TIMER);

  if (token.id in EFFECT_M_QUEUES) {
    const opts = EFFECT_M_QUEUES[token.id].opts;
    added.forEach((a) => opts.added.add(a));
    removed.forEach((a) => opts.removed.add(a));
  } else {
    EFFECT_M_QUEUES[token.id] = {
      token,
      opts: { added: new Set(added), removed: new Set(removed) },
    };
  }
  EFFECT_M_TIMER = setTimeout(callUpdateWithEffectMapping, 100);
}

async function _updateWithEffectMapping(token, added, removed) {
  const placeable = token.object ?? token._object ?? token;
  token = token.document ?? token;

  const tokenImgName = token.getFlag('token-variants', 'name') || getFileName(token.texture.src);
  let tokenDefaultImg = token.getFlag('token-variants', 'defaultImg');
  const animate = !TVA_CONFIG.disableTokenUpdateAnimation;
  const tokenUpdateObj = {};
  const hadActiveHUD = token.object?.hasActiveHUD;
  const toggleStatus = canvas.tokens.hud.object?.id === token.id ? canvas.tokens.hud._statusEffects : false;

  let effects = getTokenEffects(token);

  // If effect is included in `added` or `removed` we need to:
  // 1. Insert it into `effects` if it's not there in case of 'added' and place it on top of the list
  // 2. Remove it in case of 'removed'
  for (const ef of added) {
    const i = effects.findIndex((s) => s === ef);
    if (i === -1) {
      effects.push(ef);
    } else if (i < effects.length - 1) {
      effects.splice(i, 1);
      effects.push(ef);
    }
  }
  for (const ef of removed) {
    const i = effects.findIndex((s) => s === ef);
    if (i !== -1) {
      effects.splice(i, 1);
    }
  }

  const mappings = getAllEffectMappings(token);

  // 3. Configurations may contain effect names in a form of a logical expressions
  //    We need to evaluate them and insert them into effects/added/removed if needed
  for (const mapping of mappings) {
    evaluateMappingExpression(mapping, effects, token, added, removed);
  }

  // Accumulate all scripts that will need to be run after the update
  const executeOnCallback = [];
  const deferredUpdateScripts = [];
  for (const ef of removed) {
    const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
    if (script) {
      if (script.onRemove) {
        if (script.onRemove.includes('tvaUpdate')) deferredUpdateScripts.push(script.onRemove);
        else executeOnCallback.push({ script: script.onRemove, token });
      }
      if (script.tmfxPreset) executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'remove' });
      if (script.ceEffect?.name) executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'remove' });
      if (script.macroOnApply) executeOnCallback.push({ macro: script.macroOnApply, token });
    }
  }
  for (const ef of added) {
    const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
    if (script) {
      if (script.onApply) {
        if (script.onApply.includes('tvaUpdate')) deferredUpdateScripts.push(script.onApply);
        else executeOnCallback.push({ script: script.onApply, token });
      }
      if (script.tmfxPreset) executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'apply' });
      if (script.ceEffect?.name) executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'apply' });
      if (script.macroOnRemove) executeOnCallback.push({ macro: script.macroOnRemove, token });
    }
  }

  // Next we're going to determine what configs need to be applied and in what order
  // Filter effects that do not have a mapping and sort based on priority
  effects = mappings.filter((m) => effects.includes(m.id)).sort((ef1, ef2) => ef1.priority - ef2.priority);

  // Check if image update should be prevented based on module settings
  let disableImageUpdate = false;
  if (TVA_CONFIG.disableImageChangeOnPolymorphed && token.actor?.isPolymorphed) {
    disableImageUpdate = true;
  } else if (
    TVA_CONFIG.disableImageUpdateOnNonPrototype &&
    token.actor?.prototypeToken?.texture?.src !== token.texture.src
  ) {
    disableImageUpdate = true;
    const tknImg = token.texture.src;
    for (const m of mappings) {
      if (m.imgSrc === tknImg) {
        disableImageUpdate = false;
        break;
      }
    }
  }

  if (disableImageUpdate) {
    tokenDefaultImg = '';
  }

  let updateCall;

  if (effects.length > 0) {
    // Some effect mappings may not have images, find a mapping with one if it exists
    const newImg = { imgSrc: '', imgName: '' };

    if (!disableImageUpdate) {
      for (let i = effects.length - 1; i >= 0; i--) {
        if (effects[i].imgSrc) {
          let iSrc = effects[i].imgSrc;
          if (iSrc.includes('*') || (iSrc.includes('{') && iSrc.includes('}'))) {
            // wildcard image, if this effect hasn't been newly applied we do not want to randomize the image again
            if (!added.has(effects[i].overlayConfig?.effect)) {
              newImg.imgSrc = token.texture.src;
              newImg.imgName = getFileName(newImg.imgSrc);
              break;
            }
          }
          newImg.imgSrc = effects[i].imgSrc;
          newImg.imgName = effects[i].imgName;
          break;
        }
      }
    }

    // Collect custom configs to be applied to the token
    let config;
    if (TVA_CONFIG.stackStatusConfig) {
      config = {};
      for (const ef of effects) {
        config = mergeObject(config, ef.config);
      }
    } else {
      for (let i = effects.length - 1; i >= 0; i--) {
        if (effects[i].config && Object.keys(effects[i].config).length !== 0) {
          config = effects[i].config;
          break;
        }
      }
    }

    // Use or update the default (original) token image
    if (!newImg.imgSrc && tokenDefaultImg) {
      delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
      setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
      newImg.imgSrc = tokenDefaultImg.imgSrc;
      newImg.imgName = tokenDefaultImg.imgName;
    } else if (!tokenDefaultImg && newImg.imgSrc) {
      setProperty(tokenUpdateObj, 'flags.token-variants.defaultImg', {
        imgSrc: token.texture.src,
        imgName: tokenImgName,
      });
    }

    updateCall = () =>
      updateTokenImage(newImg.imgSrc ?? null, {
        token,
        imgName: newImg.imgName ? newImg.imgName : tokenImgName,
        tokenUpdate: tokenUpdateObj,
        callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
        config: config,
        animate,
      });
  }

  // If no mapping has been found and the default image (image prior to effect triggered update) is different from current one
  // reset the token image back to default
  if (effects.length === 0 && tokenDefaultImg) {
    delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
    setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);

    updateCall = () =>
      updateTokenImage(tokenDefaultImg.imgSrc, {
        token,
        imgName: tokenDefaultImg.imgName,
        tokenUpdate: tokenUpdateObj,
        callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
        animate,
      });
    // If no default image exists but a custom effect is applied, we still want to perform an update to
    // clear it
  } else if (effects.length === 0 && token.getFlag('token-variants', 'usingCustomConfig')) {
    updateCall = () =>
      updateTokenImage(token.texture.src, {
        token,
        imgName: tokenImgName,
        tokenUpdate: tokenUpdateObj,
        callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
        animate,
      });
  }

  if (updateCall) {
    if (deferredUpdateScripts.length) {
      for (let i = 0; i < deferredUpdateScripts.length; i++) {
        if (i === deferredUpdateScripts.length - 1) {
          await tv_executeScript(deferredUpdateScripts[i], {
            token,
            tvaUpdate: () => {
              updateCall();
            },
          });
        } else {
          await tv_executeScript(deferredUpdateScripts[i], {
            token,
            tvaUpdate: () => {},
          });
        }
      }
    } else {
      updateCall();
    }
  } else {
    if (executeOnCallback.length || deferredUpdateScripts.length) {
      _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, executeOnCallback);
      _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, deferredUpdateScripts);
    }
  }
  broadcastDrawOverlays(placeable);
}

async function _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, scripts) {
  if (hadActiveHUD && token.object) {
    canvas.tokens.hud.bind(token.object);
    if (toggleStatus) canvas.tokens.hud._toggleStatusEffects(true);
  }
  for (const scr of scripts) {
    if (scr.script) {
      await tv_executeScript(scr.script, { token: scr.token });
    } else if (scr.tmfxPreset) {
      await applyTMFXPreset(scr.token, scr.tmfxPreset, scr.action);
    } else if (scr.ceEffect) {
      await applyCEEffect(scr.token, scr.ceEffect, scr.action);
    } else if (scr.macro) {
      await executeMacro(scr.macro, token);
    }
  }
}

export function getAllEffectMappings(token = null, includeDisabled = false) {
  let allMappings = getFlagMappings(token);
  const unique = new Set();

  // TODO: replace with a setting
  allMappings.forEach((m) => unique.add(TVA_CONFIG.mergeGroup ? m.group : m.label));

  // Sort out global mappings that do not apply to this actor
  let applicableGlobal = TVA_CONFIG.globalMappings;
  if (token?.actor?.type) {
    const actorType = token.actor.type;
    applicableGlobal = applicableGlobal.filter((m) => {
      if (!m.targetActors || m.targetActors.includes(actorType)) {
        return !unique.has(TVA_CONFIG.mergeGroup ? m.group : m.label);
      }
      return false;
    });
  }
  allMappings = allMappings.concat(applicableGlobal);

  if (!includeDisabled) allMappings = allMappings.filter((m) => !m.disabled);

  return allMappings;
}

export async function setOverlayVisibility({
  userName = null,
  userId = null,
  label = null,
  group = null,
  token = null,
  visible = true,
} = {}) {
  if (!label && !group) return;
  if (userName) userId = game.users.find((u) => u.name === userName)?.id;
  if (!userId) return;

  let tokenMappings = getFlagMappings(token);
  let globalMappings = TVA_CONFIG.globalMappings;

  let updateToken = false;
  let updateGlobal = false;

  const updateMappings = function (mappings) {
    mappings = mappings.filter((m) => m.overlay && (m.label === label || m.group === group));
    let found = false;
    if (mappings.length) found = true;

    mappings.forEach((m) => {
      const overlayConfig = m.overlayConfig;
      if (visible) {
        if (!overlayConfig.limitedUsers) overlayConfig.limitedUsers = [];
        if (!overlayConfig.limitedUsers.find((u) => u === userId)) overlayConfig.limitedUsers.push(userId);
      } else if (overlayConfig.limitedUsers) {
        overlayConfig.limitedUsers = overlayConfig.limitedUsers.filter((u) => u !== userId);
      }
    });
    return found;
  };

  updateToken = updateMappings(tokenMappings);
  updateGlobal = updateMappings(globalMappings);

  if (updateGlobal) await updateSettings({ globalMappings: globalMappings });
  if (updateToken) {
    const actor = game.actors.get(token.document.actorId);
    if (actor) await actor.setFlag('token-variants', 'effectMappings', tokenMappings);
  }
  if (updateToken || updateGlobal) drawOverlays(token);
}

function _getTemplateMappings(templateName) {
  return (
    TVA_CONFIG.templateMappings.find((t) => t.name === templateName) ??
    CORE_TEMPLATES.find((t) => t.name === templateName)
  )?.mappings;
}

export async function applyTemplate(token, templateName = null, mappings = null) {
  if (templateName) mappings = _getTemplateMappings(templateName);
  if (!token || !mappings) return;

  const actor = game.actors.get(token.actor.id);
  if (!actor) return;
  const templateMappings = deepClone(mappings);
  templateMappings.forEach((tm) => (tm.tokens = [token.id]));

  const actMappings = mergeMappings(templateMappings, getFlagMappings(actor));
  await actor.setFlag('token-variants', 'effectMappings', actMappings);
  await updateWithEffectMapping(token);
  drawOverlays(token);
}

export async function removeTemplate(token, templateName = null, mappings = null) {
  if (templateName) mappings = _getTemplateMappings(templateName);
  if (!token || !mappings) return;

  const actor = game.actors.get(token.actor.id);
  if (!actor) return;

  let actMappings = getFlagMappings(actor);
  mappings.forEach((m) => {
    let i = actMappings.findIndex((m2) => m2.id === m.id);
    if (i !== -1) {
      actMappings[i].tokens = actMappings[i].tokens.filter((t) => t !== token.id);
      if (actMappings[i].tokens.length === 0) actMappings.splice(i, 1);
    }
  });

  if (actMappings.length) await actor.setFlag('token-variants', 'effectMappings', actMappings);
  else await actor.unsetFlag('token-variants', 'effectMappings');
  await updateWithEffectMapping(token);
  drawOverlays(token);
}

export function toggleTemplate(token, templateName = null, mappings = null) {
  if (templateName) mappings = _getTemplateMappings(templateName);
  if (!token || !mappings) return;

  const actor = game.actors.get(token.actor.id);
  if (!actor) return;

  const actMappings = getFlagMappings(actor);
  if (actMappings.some((m) => mappings.some((m2) => m2.id === m.id && m.tokens?.includes(token.id)))) {
    removeTemplate(token, null, mappings);
  } else {
    applyTemplate(token, null, mappings);
  }
}

export function toggleTemplateOnSelected(templateName = null, mappings = null) {
  canvas.tokens.controlled.forEach((t) => toggleTemplate(t, templateName, mappings));
}

function getHPChangeEffect(token, effects) {
  const internals = token.actor?.getFlag('token-variants', 'internalEffects') || {};
  const delta = getProperty(
    token,
    `${isNewerVersion('11', game.version) ? 'actorData' : 'delta'}.flags.token-variants.internalEffects`
  );
  if (delta) mergeObject(internals, delta);
  if (internals['hp--'] != null) effects.push('hp--');
  if (internals['hp++'] != null) effects.push('hp++');
}

function applyHpChangeEffect(actor, change, tokens) {
  let duration = Number(TVA_CONFIG.internalEffects.hpChange.duration);

  const newHpValue = getProperty(change, `system.${TVA_CONFIG.systemHpPath}.value`);
  if (newHpValue != null) {
    const [currentHpVal, _] = getTokenHP(tokens[0]);
    if (currentHpVal !== newHpValue) {
      if (currentHpVal < newHpValue) {
        setProperty(change, 'flags.token-variants.internalEffects.-=hp--', null);
        setProperty(change, 'flags.token-variants.internalEffects.hp++', newHpValue - currentHpVal);
        if (duration) {
          setTimeout(() => {
            actor.update({
              'flags.token-variants.internalEffects.-=hp++': null,
            });
          }, duration * 1000);
        }
      } else {
        setProperty(change, 'flags.token-variants.internalEffects.-=hp++', null);
        setProperty(change, 'flags.token-variants.internalEffects.hp--', newHpValue - currentHpVal);
        if (duration) {
          setTimeout(() => {
            actor.update({
              'flags.token-variants.internalEffects.-=hp--': null,
            });
          }, duration * 1000);
        }
      }
    }
  }
}

export function getTokenEffects(token, includeExpressions = false) {
  const data = token.document ?? token;
  let effects = [];

  // TVA Effects
  const tokenInCombat = game.combats.some((combat) => {
    return combat.combatants.some((c) => c.tokenId === token.id);
  });
  if (tokenInCombat) {
    effects.push('token-variants-combat');
  }

  if (game.combat?.started) {
    if (game.combat?.combatant?.token?.id === token.id) {
      effects.push('combat-turn');
    } else if (game.combat?.nextCombatant?.token?.id === token.id) {
      effects.push('combat-turn-next');
    }
  }
  if (data.hidden) {
    effects.push('token-variants-visibility');
  }

  if (TVA_CONFIG.internalEffects.hpChange.enabled) {
    getHPChangeEffect(data, effects);
  }

  // Actor/Token effects
  if (data.actorLink) {
    getEffectsFromActor(token.actor, effects);
  } else {
    if (game.system.id === 'pf2e') {
      (data.delta?.items || []).forEach((item) => {
        if (_activePF2EItem(item)) {
          effects.push(item.name);
        }
      });
    } else {
      (data.effects || []).filter((ef) => !ef.disabled && !ef.isSuppressed).forEach((ef) => effects.push(ef.label));
      getEffectsFromActor(token.actor, effects);
    }
  }

  // Expression/Mapping effects
  evaluateComparatorEffects(token, effects);
  evaluateStateEffects(token, effects);

  // Include mappings marked as always applicable
  // as well as the ones defined as logical expressions if needed
  const mappings = getAllEffectMappings(token);

  for (const m of mappings) {
    if (m.tokens?.length && !m.tokens.includes(data.id)) continue;
    if (m.alwaysOn) effects.unshift(m.id);
    else if (includeExpressions) {
      const evaluation = evaluateMappingExpression(m, effects, token);
      if (evaluation) effects.unshift(m.id);
    }
  }

  return effects;
}

export function getEffectsFromActor(actor, effects = []) {
  if (!actor) return effects;

  if (game.system.id === 'pf2e') {
    (actor.items || []).forEach((item, id) => {
      if (_activePF2EItem(item)) effects.push(item.name);
    });
  } else {
    (actor.effects || []).forEach((activeEffect, id) => {
      if (!activeEffect.disabled && !activeEffect.isSuppressed) effects.push(activeEffect.name ?? activeEffect.label);
    });
    (actor.items || []).forEach((item) => {
      if (ITEM_TYPES.includes(item.type) && item.system.equipped) effects.push(item.name ?? item.label);
    });
  }

  return effects;
}

function _activePF2EItem(item) {
  if (PF2E_ITEM_TYPES.includes(item.type)) {
    if ('active' in item) {
      return item.active;
    } else if ('isEquipped' in item) {
      return item.isEquipped;
    } else {
      return true;
    }
  }
  return false;
}

export const VALID_EXPRESSION = new RegExp('([a-zA-Z\\-\\.\\+]+)([><=]+)(".*"|-?\\d+)(%{0,1})');

export function evaluateComparator(token, expression) {
  const match = expression.match(VALID_EXPRESSION);
  if (match) {
    const property = match[1];

    let currVal;
    let maxVal;
    if (property === 'hp') {
      [currVal, maxVal] = getTokenHP(token);
    } else if (property === 'hp++' || property === 'hp--') {
      [currVal, maxVal] = getTokenHP(token);
      currVal = getProperty(token, `actor.flags.token-variants.internalEffects.${property}`) ?? 0;
    } else currVal = getProperty(token, property);
    if (currVal == null) currVal = 0;

    const sign = match[2];
    let val = Number(match[3]);
    if (isNaN(val)) {
      val = match[3].substring(1, match[3].length - 1);
      if (val === 'true') val = true;
      if (val === 'false') val = false;
      // Convert currVal to a truthy/falsy one if this is a bool check
      if (val === true || val === false) {
        if (isEmpty(currVal)) currVal = false;
        else currVal = Boolean(currVal);
      }
    }
    const isPercentage = Boolean(match[4]);

    if (property === 'rotation') {
      maxVal = 360;
    } else if (maxVal == null) {
      maxVal = 999999;
    }
    const toCompare = isPercentage ? (currVal / maxVal) * 100 : currVal;

    let passed = false;
    if (sign === '=') {
      passed = toCompare == val;
    } else if (sign === '>') {
      passed = toCompare > val;
    } else if (sign === '<') {
      passed = toCompare < val;
    } else if (sign === '>=') {
      passed = toCompare >= val;
    } else if (sign === '<=') {
      passed = toCompare <= val;
    } else if (sign === '<>') {
      passed = toCompare < val || toCompare > val;
    }
    return passed;
  }
  return false;
}

export function evaluateComparatorEffects(token, effects = []) {
  token = token.document ?? token;

  const mappings = getAllEffectMappings(token);

  const matched = new Set();

  for (const m of mappings) {
    const expressions = m.expression
      .split(EXPRESSION_MATCH_RE)
      .filter(Boolean)
      .map((exp) => exp.trim())
      .filter(Boolean);
    for (let i = 0; i < expressions.length; i++) {
      if (evaluateComparator(token, expressions[i])) {
        matched.add(expressions[i]);
      }
    }
  }

  // Remove duplicate expressions and insert into effects
  matched.forEach((exp) => effects.unshift(exp));

  return effects;
}

export function evaluateStateEffects(token, effects) {
  if (game.system.id === 'pf2e') {
    const deathIcon = game.settings.get('pf2e', 'deathIcon');
    if ((token.document ?? token).overlayEffect === deathIcon) effects.push('Dead');
  }
}

/**
 * Replaces {1,a,5,b} type string in the expressions with (1|a|5|b)
 * @param {*} exp
 * @returns
 */
function _findReplaceBracketWildcard(exp) {
  let nExp = '';
  let lIndex = 0;
  while (lIndex >= 0) {
    let i1 = exp.indexOf('\\\\\\{', lIndex);
    if (i1 !== -1) {
      let i2 = exp.indexOf('\\\\\\}', i1);
      if (i2 !== -1) {
        nExp += exp.substring(lIndex, i1);
        nExp +=
          '(' +
          exp
            .substring(i1 + 4, i2)
            .split(',')
            .join('|') +
          ')';
      }
      lIndex = i2 + 4;
    } else {
      return nExp + exp.substring(lIndex, exp.length);
    }
  }
  return nExp ?? exp;
}

function _testRegExEffect(effect, effects) {
  let re = effect.replace(/[/\-\\^$+?.()|[\]{}]/g, '\\$&').replaceAll('\\\\*', '.*');
  re = _findReplaceBracketWildcard(re);
  re = new RegExp('^' + re + '$');
  return effects.find((ef) => re.test(ef));
}

export function evaluateMappingExpression(mapping, effects, token, added = new Set(), removed = new Set()) {
  let arrExpression = mapping.expression
    .split(EXPRESSION_MATCH_RE)
    .filter(Boolean)
    .map((s) => s.trim())
    .filter(Boolean);

  let temp = '';
  let hasAdded = false;
  let hasRemoved = false;
  for (let exp of arrExpression) {
    if (EXPRESSION_OPERATORS.includes(exp)) {
      temp += exp.replace('\\', '');
      continue;
    }

    if (/\\\*|\\{.*\\}/g.test(exp)) {
      let rExp = _testRegExEffect(exp, effects);
      if (rExp) {
        temp += 'true';
      } else {
        temp += 'false';
      }

      if (_testRegExEffect(exp, added)) hasAdded = true;
      else if (_testRegExEffect(exp, removed)) hasRemoved = true;
      continue;
    } else if (effects.includes(exp)) {
      temp += 'true';
    } else {
      temp += 'false';
    }

    if (!hasAdded && added.has(exp)) hasAdded = true;
    if (!hasRemoved && removed.has(exp)) hasRemoved = true;
  }

  try {
    let evaluation = eval(temp);

    // Evaluate JS code
    if (mapping.codeExp) {
      try {
        token = token.document ?? token;
        if (!eval(mapping.codeExp)) evaluation = false;
        else if (!mapping.expression) evaluation = true;
      } catch (e) {
        evaluation = false;
      }
    }

    if (evaluation) {
      if (hasAdded || hasRemoved) {
        added.add(mapping.id);
        effects.push(mapping.id);
      } else effects.unshift(mapping.id);
    } else if (hasRemoved || hasAdded) {
      removed.add(mapping.id);
    }
    return evaluation;
  } catch (e) {}
  return false;
}

function _getTokenHPv11(token) {
  let attributes;

  if (token.actorLink) {
    attributes = getProperty(token.actor?.system, TVA_CONFIG.systemHpPath);
  } else {
    attributes = mergeObject(
      getProperty(token.actor?.system, TVA_CONFIG.systemHpPath) || {},
      getProperty(token.delta?.system) || {},
      {
        inplace: false,
      }
    );
  }

  return [attributes?.value, attributes?.max];
}

export function getTokenHP(token) {
  if (!isNewerVersion('11', game.version)) return _getTokenHPv11(token);

  let attributes;

  if (token.actorLink) {
    attributes = getProperty(token.actor.system, TVA_CONFIG.systemHpPath);
  } else {
    attributes = mergeObject(
      getProperty(token.actor.system, TVA_CONFIG.systemHpPath) || {},
      getProperty(token.actorData?.system) || {},
      {
        inplace: false,
      }
    );
  }
  return [attributes?.value, attributes?.max];
}

async function _updateImageOnEffectChange(effectName, actor, added = true) {
  const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  for (const token of tokens) {
    await updateWithEffectMapping(token, {
      added: added ? [effectName] : [],
      removed: !added ? [effectName] : [],
    });
  }
}

async function _updateImageOnMultiEffectChange(actor, added = [], removed = []) {
  if (!actor) return;
  const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  for (const token of tokens) {
    await updateWithEffectMapping(token, {
      added: added,
      removed: removed,
    });
  }
}

async function _deleteCombatant(combatant) {
  const token = combatant._token || canvas.tokens.get(combatant.tokenId);
  if (!token || !token.actor) return;
  await updateWithEffectMapping(token, {
    removed: ['token-variants-combat'],
  });
}
