import {SharedConsts} from "../shared/SharedConsts.js";
import {UtilActors} from "./UtilActors.js";
import {Config} from "./Config.js";
import {UtilApplications} from "./UtilApplications.js";
import {Vetools} from "./Vetools.js";
import {Consts} from "./Consts.js";
import {
	Charactermancer_SkillProficiencySelect,
	Charactermancer_LanguageProficiencySelect,
	Charactermancer_ToolProficiencySelect,
	Charactermancer_ArmorProficiencySelect,
	Charactermancer_WeaponProficiencySelect,
	Charactermancer_LanguageToolProficiencySelect,
	Charactermancer_ConditionImmunitySelect,
	Charactermancer_DamageVulnerabilitySelect,
	Charactermancer_DamageResistanceSelect,
	Charactermancer_DamageImmunitySelect,
} from "./UtilCharactermancer.js";
import {LGT, Util} from "./Util.js";
import {UtilCompat} from "./UtilCompat.js";

class DataConverter {
	static getNameWithSourcePart (ent, {displayName = null, isActorItem = false} = {}) {
		return `${displayName || Renderer.stripTags(ent._displayName || ent.name || "")}${!isActorItem && ent.source && Config.get("import", "isAddSourceToName") ? ` (${Parser.sourceJsonToAbv(ent.source)})` : ""}`
	}

	static getSourceWithPagePart (ent) {
		return `${Parser.sourceJsonToAbv(ent.source)}${Config.get("import", "isAddPageNumberToSource") && ent.page ? `${DataConverter._SOURCE_PAGE_PREFIX}${ent.page}` : ""}`
	}

	static getItemSource (itm) {
		let data = itm.data || {};
		if (data.data) data = data.data;
		let rawSource = data.source;

		// region vtta-dndbeyond workaround
		// This is invalid, but as of 2020-12-15, vtta-dndbeyond can create an array of sources on race items on imported
		//   characters. Tolerate it.
		if (rawSource instanceof Array) rawSource = rawSource[0];
		// endregion

		if (!rawSource) return rawSource;
		return rawSource.split(DataConverter._SOURCE_PAGE_PREFIX_RE)[0].trim();
	}

	static getTagUids (tag, str) {
		const re = new RegExp(`{@${tag} ([^}]+)}`, "gi");
		const out = [];
		str.replace(re, (...m) => out.push(m[1]));
		return out;
	}

	static async pGetIconImage (entityType, entity) {
		switch (entityType) {
			case "feat":
			case "spell": {
				const iconLookup = await Vetools.pGetIconLookup(entityType);
				const path = MiscUtil.get(iconLookup, entity.source, entity.name);
				if (!path) return null;
				return `modules/${SharedConsts.MODULE_NAME}/${path}`;
			}
			default: return null;
		}
	}

	/** Combine Foundry-specific data found on entries. Pure takes precedence over underscore. */
	static getCombinedFoundryData (foundryData, _foundryData) {
		if (!_foundryData && !foundryData) return {};

		const combinedFoundryData = MiscUtil.copy(_foundryData || {});
		Object.assign(combinedFoundryData, MiscUtil.copy(foundryData || {}));

		return combinedFoundryData;
	}

	/** Combine Foundry-specific flags found on entries. Pure takes precedence over underscore. */
	static getCombinedFoundryFlags (foundryFlags, _foundryFlags) {
		if (!foundryFlags && !_foundryFlags) return {};

		const combinedFoundryFlags = MiscUtil.copy(_foundryFlags || {});

		// Since flags are namespaced at the outer level, overwrite entries at one level deep.
		Object.entries(MiscUtil.copy(foundryFlags || {}))
			.forEach(([flagNamespace, flagData]) => {
				if (!combinedFoundryFlags[flagNamespace]) return combinedFoundryFlags[flagNamespace] = flagData;
				Object.assign(combinedFoundryFlags[flagNamespace], flagData);
			});

		return combinedFoundryFlags;
	}

	/**
	 * @param entry Entry to render.
	 * @param [opts] Options object.
	 * @param [opts.prop] Entry property.
	 * @param [opts.depth] Render depth.
	 * @param [opts.summonSpellLevel]
	 * @return {string}
	 */
	static getEntryDescription (entry, opts) {
		opts = opts || {};
		opts.prop = opts.prop || "entries";

		let description = "";
		if (entry[opts.prop]) {
			let cpyEntries = MiscUtil.copy(entry[opts.prop]);

			cpyEntries = DataConverter.WALKER_GENERIC.walk(
				cpyEntries,
				{
					string: (str) => {
						return str
							// Replace `@hitYourSpellAttack`
							.replace(/{@hitYourSpellAttack}/gi, () => `{@dice 1d20 + @srd5e.userchar.spellAttackRanged|your spell attack modifier}`)
							.replace(/{(@dice|@damage|@scaledice|@scaledamage) ([^}]+)}/gi, (...m) => {
								const [, tag, text] = m;
								let [rollText, displayText, name, ...others] = Renderer.splitTagByPipe(text);
								const originalRollText = rollText;

								rollText = rollText
									// Replace `+ PB`
									.replace(/\bPB\b/gi, `@srd5e.userchar.pb`)
									// Replace `+ summonSpellLevel`
									.replace(/\bsummonSpellLevel\b/gi, `${opts.summonSpellLevel ?? 0}`)

								// If we did any replaces, ensure the display text does not change
								if (!displayText && originalRollText !== rollText) {
									displayText = originalRollText
										// Replace `+ summonSpellLevel`
										.replace(/\bsummonSpellLevel\b/gi, `the spell's level`)
								}

								return `{${tag} ${[rollText, displayText || "", name || "", ...others].join("|")}}`;
							})
						;
					},
				},
			)

			description = DataConverter.getWithDescriptionPlugins(
				() => Renderer.get().setFirstSection(true).render(
					{
						type: "entries",
						entries: cpyEntries,
					},
					opts.depth != null ? opts.depth : 2,
				),
			);
		}

		return description;
	}

	static getWithDescriptionPlugins (fn) {
		return Renderer.get().withPlugin({
			entryType: "link",
			pluginType: "attributesHover",
			fnPlugin: (entry, procHash) => this._getWithDescriptionPlugins_fnPlugin(entry, procHash),
			fn,
		});
	}

	static async pGetWithDescriptionPlugins (pFn) {
		return Renderer.get().pWithPlugin({
			entryType: "link",
			pluginType: "attributesHover",
			fnPlugin: (entry, procHash) => this._getWithDescriptionPlugins_fnPlugin(entry, procHash),
			pFn,
		});
	}

	static _getWithDescriptionPlugins_fnPlugin (entry, procHash) {
		const hoverMeta = {
			page: entry.href.hover.page,
			source: entry.href.hover.source,
			hash: procHash,
			preloadId: entry.href.hover.preloadId,
		};
		return {attributesHoverReplace: [`data-plut-hover='${JSON.stringify(hoverMeta).qq()}'`]};
	}

	/**
	 * Note that (where appropriate) updates should first load the actor's data, copy it, and modify it to suit.
	 *
	 * @param actor The actor's current data.
	 * @param actorUpdate The actor update data to be mutated.
	 * @param entry Entry to process.
	 * @param [opts] Options object.
	 * @param [opts.sideData] Side-loaded Foundry data.
	 * @param [opts.sideData.actorDataMod] Generic actor data modifications to apply.
	 * @param [opts.sideData.actorTokenMod] Generic actor token modifications to apply.
	 */
	static mutActorUpdate (actor, actorUpdate, entry, opts) {
		opts = opts || {};

		this._mutActorUpdate_mutFromSideDataMod(actor, actorUpdate, opts);
		this._mutActorUpdate_mutFromSideTokenMod(actor, actorUpdate, opts);
	}

	static _mutActorUpdate_mutFromSideDataMod (actor, actorUpdate, opts) {
		return this._mutActorUpdate_mutFromSideMod(actor, actorUpdate, opts, "actorDataMod", "data");
	}

	static _mutActorUpdate_mutFromSideTokenMod (actor, actorUpdate, opts) {
		return this._mutActorUpdate_mutFromSideMod(actor, actorUpdate, opts, "actorTokenMod", "token");
	}

	static _mutActorUpdate_mutFromSideMod (actor, actorUpdate, opts, sideProp, actorProp) {
		if (!opts.sideData || !opts.sideData[sideProp]) return;

		Object.entries(opts.sideData[sideProp])
			.forEach(([path, modMetas]) => this._mutActorUpdate_mutFromSideMod_handleProp(actor, actorUpdate, opts, sideProp, actorProp, path, modMetas));
	}

	static _mutActorUpdate_mutFromSideMod_handleProp (actor, actorUpdate, opts, sideProp, actorProp, path, modMetas) {
		const pathParts = path.split(".");

		// "Special" paths
		if (path === "_") {
			modMetas.forEach(modMeta => {
				switch (modMeta.mode) {
					case "conditionals": {
						for (const cond of modMeta.conditionals) {
							// If there is no condition (i.e. this is the "else" branch, always run.
							//   Otherwise, evaluate the condition.

							// Create a context for our variables to avoid any minification renames
							window.PLUT_CONTEXT = {actor};

							// eslint-disable-next-line no-eval
							if (cond.condition && !eval(cond.condition)) continue;

							Object.entries(cond.mod)
								.forEach(([path, modMetas]) => this._mutActorUpdate_mutFromSideMod_handleProp(actor, actorUpdate, opts, sideProp, actorProp, path, modMetas));

							break;
						}

						break;
					}

					default: throw new Error(`Unhandled mode "${modMeta.mode}"`);
				}
			});
			return;
		}

		const fromActor = MiscUtil.get(actor, "data", actorProp, ...pathParts);
		const fromUpdate = MiscUtil.get(actorUpdate, actorProp, ...pathParts);
		const existing = fromUpdate || fromActor;

		modMetas.forEach(modMeta => {
			switch (modMeta.mode) {
				case "appendStr": {
					const existing = MiscUtil.get(actorUpdate, actorProp, ...pathParts);
					const next = existing ? `${existing}${modMeta.joiner || ""}${modMeta.str}` : modMeta.str;
					MiscUtil.set(actorUpdate, actorProp, ...pathParts, next);
					break;
				}

				case "appendIfNotExistsArr": {
					const existingArr = MiscUtil.copy(existing || []);
					const out = [...existingArr];
					out.push(...modMeta.items.filter(it => !existingArr.some(x => CollectionUtil.deepEquals(it, x))));
					MiscUtil.set(actorUpdate, actorProp, ...pathParts, out);
					break;
				}

				case "scalarAdd": {
					MiscUtil.set(actorUpdate, actorProp, ...pathParts, modMeta.scalar + existing || 0);
					break;
				}

				case "scalarAddUnit": {
					const existingLower = `${existing || 0}`.toLowerCase();

					const handle = (toFind) => {
						const ix = existingLower.indexOf(toFind.toLowerCase());
						let numPart = existing.slice(0, ix);
						const rest = existing.slice(ix);
						const isSep = numPart.endsWith(" ");
						numPart = numPart.trim();

						if (!isNaN(numPart)) {
							const out = `${modMeta.scalar + Number(numPart)}${isSep ? " " : ""}${rest}`;
							MiscUtil.set(actorUpdate, actorProp, ...pathParts, out);
						} // else silently fail
					}

					if (!existing) MiscUtil.set(actorUpdate, actorProp, ...pathParts, `${modMeta.scalar} ${modMeta.unitShort || modMeta.unit}`);
					else if (modMeta.unit && existingLower.includes(modMeta.unit.toLowerCase())) {
						handle(modMeta.unit);
					} else if (modMeta.unitShort && existingLower.includes(modMeta.unitShort.toLowerCase())) {
						handle(modMeta.unitShort);
					} // else silently fail
					break;
				}

				// Set something to the numerical max of its existing value and the incoming value
				case "setMax": {
					const existingLower = `${existing || 0}`.toLowerCase();
					let asNum = Number(existingLower);
					if (isNaN(asNum)) asNum = 0;
					const maxValue = Math.max(asNum, modMeta.value);
					MiscUtil.set(actorUpdate, actorProp, ...pathParts, maxValue);
					break;
				}

				case "set": {
					MiscUtil.set(actorUpdate, actorProp, ...pathParts, MiscUtil.copy(modMeta.value));
					break;
				}

				default: throw new Error(`Unhandled mode "${modMeta.mode}"`);
			}
		});
	}

	/**
	 * Generic entry -> item data parsing.
	 * @param entry Entry to process.
	 * @param opts Options object.
	 * @param opts.mode Parsing mode; `"creature"` or `"player" or "object"`.
	 * @param [opts.modeOptions] Parsing mode options.
	 * @param [opts.modeOptions.isChannelDivinity]
	 * @param opts.fvttType Foundry type.
	 * @param opts.img Image path.
	 * @param [opts.effects] Item effects.
	 * @param opts.source Item source.
	 * @param [opts.renderDepth] Render depth.
	 * @param [opts.activationType] Foundry activation type.
	 * @param [opts.activationCost] Foundry action type.
	 * @param [opts.consumeType]
	 * @param [opts.consumeTarget]
	 * @param [opts.consumeAmount]
	 * @param [opts.formula]
	 * @param [opts.description] Pre-rendered description.
	 * @param [opts.isSkipDescription] If a description should not be created from the entry.
	 * @param [opts.actor] Actor data.
	 * @param [opts.summonSpellLevel] Spell level used to summon the creature this item will belong to, if appropriate.
	 * @param [opts.additionalData] Additional side-loaded Foundry data.
	 * @param [opts.pb] A proficiency bonus for this actor. Used for creature text parsing.
	 * @param [opts.entity] An entity with str/dex/con/int/wis/cha scores. Used for creature text parsing.
	 * @param [opts.entity.str]
	 * @param [opts.entity.dex]
	 * @param [opts.entity.con]
	 * @param [opts.entity.int]
	 * @param [opts.entity.wis]
	 * @param [opts.entity.cha]
	 */
	static getItemActorPassive (entry, opts) {
		opts = opts || {};
		opts.modeOptions = opts.modeOptions || {};

		// Treat object parses as creature parses; potentially to be reworked in future.
		if (opts.mode === "object") opts.mode = "creature";

		// region Inputs
		let {
			description,

			activationType,
			activationCost,
			activationCondition,

			saveAbility,
			saveDc,
			saveScaling,

			damageParts,

			attackBonus,

			requirements,

			actionType,

			durationValue,
			durationUnits,

			consumeType,
			consumeTarget,
			consumeAmount,

			formula,

			targetValue,
			targetUnits,
			targetType,

			usesCount,
			usesCountMax,
			usesPer,

			foundryData,
			_foundryData,
			foundryFlags,
			_foundryFlags,
		} = opts;

		const combinedFoundryData = DataConverter.getCombinedFoundryData(foundryData, _foundryData);
		const combinedFoundryFlags = DataConverter.getCombinedFoundryFlags(foundryFlags, _foundryFlags);

		description = description || (opts.isSkipDescription ? "" : DataConverter.getEntryDescription(entry, {depth: opts.renderDepth, summonSpellLevel: opts.summonSpellLevel}));

		activationType = activationType || "";
		activationCost = activationCost || 0;
		activationCondition = activationCondition || "";

		saveAbility = saveAbility || "";
		saveDc = saveDc || null;
		saveScaling = saveScaling || "";

		damageParts = damageParts || [];

		attackBonus = attackBonus || 0;

		requirements = requirements || "";

		actionType = actionType || "";

		durationValue = durationValue || null;
		durationUnits = durationUnits || "";

		consumeType = consumeType || null;
		consumeTarget = consumeTarget || null;
		consumeAmount = consumeAmount || null;

		formula = formula || "";

		targetValue = targetValue || null;
		targetUnits = targetUnits || "";
		targetType = targetType || "";

		usesCount = usesCount || 0; // This is always a number
		usesCountMax = usesCountMax || "0"; // This can be a formula
		usesPer = usesPer || null; // Foundry currently (2020-04-26) supports "Day", "Short Rest", "Long Rest", and "Charges"
		// endregion

		let name = UtilApplications.getCleanEntityName(DataConverter.getNameWithSourcePart(entry, {isActorItem: true}));
		const strEntries = entry.entries ? JSON.stringify(entry.entries) : null;

		// region Recharge
		let rechargeOn = null;
		if (entry.name) {
			const mRecharge = /{@recharge( \d+)?}/gi.exec(entry.name);
			if (mRecharge) {
				rechargeOn = mRecharge[1] ? Number(mRecharge[1].trim()) : 6;
				name = name
					.replace(/\(Recharge \d+(–\d+)?\)/gi, "")
					.trim()
					.replace(/[ ]+/g, " ")
				;
			}
		}
		// endregion

		// region Activation
		if (entry.entries) {
			// For creatures, bonus actions end up in traits, so pull these out
			// Accept "bonus action" in any part of the string, as even if it's a conditional bonus action, the user
			//   should prefer having it available to activate.
			if (/(?:^|\W)bonus action(?:\W|$)/i.test(strEntries)) {
				activationType = activationType || "bonus";
				activationCost = activationCost || 1;
			}

			// For other features, actions/reaction
			if (opts.mode === "player") {
				if (!activationType && !activationCost) {
					const walker = DataConverter.WALKER_READONLY_GENERIC;
					walker.walk(
						entry.entries,
						{
							string: (str) => {
								if (activationType) return str;

								const sentences = Util.getSentences(str);
								for (const sentence of sentences) {
									if (/as an action|can take an action/i.test(sentence)) {
										activationType = activationType || "action";
										activationCost = activationCost || 1;
										break;
									}

									const mReact = /you can use your reaction|using your reaction|you can use this special reaction/i.exec(sentence);
									if (mReact) {
										activationType = activationType || "reaction";
										activationCost = activationCost || 1;

										// Attempt to pull out a condition from the preceding text, e.g.
										// "When an enemy within 30 feet of you, ..., you can use your reaction"
										let preceding = sentence.slice(0, mReact.index).trim().replace(/,$/, "");
										const mCondition = /(^|\W)(?:if|when)(?:|\W)/i.exec(preceding);
										if (mCondition) {
											preceding = preceding.slice(mCondition.index + mCondition[1].length).trim();
											activationCondition = activationCondition || preceding;
										}

										break;
									}
								}
							},
						},
					)

					// If we could not find a well-defined action type, fall back on "special" in some cases
					if (!activationType) {
						walker.walk(
							entry.entries,
							{
								string: (str) => {
									if (activationType) return str;

									const sentences = Util.getSentences(str);

									for (const sentence of sentences) {
										if (/you can't use this feature again|once you use this feature/i.test(sentence)) activationType = "special";
									}
								},
							},
						)
					}
				}
			}

			if (opts.mode === "creature" && entry.name) {
				if (!activationType && !activationCost) {
					if (/^legendary resistance/i.test(entry.name)) {
						activationType = "special";
					}
				}
			}
		}
		// endregion

		// region Uses
		// For creatures, use information is given in the name
		if (opts.mode === "creature" && entry.name) {
			name = name.replace(/\(\s*(\d+)\s*\/\s*(Day|Short Rest|Long Rest)\s*\)/i, (...m) => {
				usesCount = Number(m[1]);
				usesCountMax = `${usesCount}`;

				const cleanTime = m[2].trim().toLowerCase();
				switch (cleanTime) {
					case "day": usesPer = "day"; break;
					case "short rest": usesPer = "sr"; break;
					case "long rest": usesPer = "lr"; break;
				}

				return "";
			});

			if (!usesPer) {
				name = name.replace(/\(\s*(\d+)\s+Charges\s*\)/i, (...m) => {
					usesCount = Number(m[1]);
					usesCountMax = `${usesCount}`;
					usesPer = "charges";

					return "";
				});
			}

			if (usesPer) { // If we found any "uses" part
				// Clean the name after doing any replaces
				name = name.trim().replace(/[ ]+/g, " ");

				// Force an activation cost if we have an activation cost, as it is required to display the form correctly
				activationType = activationType || "none";

				// If the first line of the entries starts with "If ..." or "When ...", use the first line as the condition
				if (entry.entries && typeof entry.entries[0] === "string" && /^(?:If |When )/i.test(entry.entries[0].trim())) {
					activationCondition = entry.entries[0].trim();
				}
			}

			// Avoid adding usage info to legendary resistances, as they will be tracked as resource usage on the sheet
			if (/legendary resistance/gi.test(name)) {
				usesCount = 0;
				usesCountMax = `${usesCount}`;
				usesPer = null;
			}
		}

		if (opts.mode === "player" && entry.entries) {
			// region Resting
			const isShortRest = /finish a short rest/.test(strEntries) || /finish a short or long rest/.test(strEntries) || /finish a short rest or a long rest/.test(strEntries) || /finish a short or long rest/.test(strEntries);
			const isLongRest = !isShortRest && /finish a long rest/.test(strEntries);

			if (isShortRest) usesPer = "sr";
			else if (isLongRest) usesPer = "lr";
			// endregion

			// region Uses
			const reAbilModifier = new RegExp(`a number of times equal to(?: (${Consts.TERMS_COUNT.map(it => it.tokens.join("")).join("|")}))? your (Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) modifier(?: \\(minimum of (${Consts.TERMS_COUNT.map(it => it.tokens.join("")).join("|")})\\))?`, "i");
			const mAbilModifier = reAbilModifier.exec(strEntries);
			if (mAbilModifier && opts.actor) {
				const abv = mAbilModifier[2].slice(0, 3).toLowerCase();
				const abilScore = MiscUtil.get(opts.actor, "data", "data", "abilities", abv, "value");
				if (abilScore != null) {
					let mod = Parser.getAbilityModNumber(abilScore);
					// FIXME(Future) Test/use the proper version when dnd5e 1.3.0 is released
					// let modFormula = `@abilities.${abv}.mod`;
					let modFormula = `floor((@abilities.${abv}.value - 10) / 2)`;

					if (mAbilModifier[1]) {
						const multiplier = (Consts.TERMS_COUNT.find(it => it.tokens.join(" ") === mAbilModifier[1].trim().toLowerCase()) || {}).count || 1;
						mod = mod * multiplier;
						modFormula = `${modFormula} * ${multiplier}`;
					}

					if (mAbilModifier[3]) {
						const min = (Consts.TERMS_COUNT.find(it => it.tokens.join("") === mAbilModifier[3].trim().toLowerCase()) || {}).count || 1;
						mod = Math.max(min, mod);
						modFormula = `max(${min}, ${modFormula})`;
					}

					usesCount = mod;
					usesCountMax = modFormula;
				}
			}

			// If we found no use count, assume it's one-use
			if (usesPer && !usesCount && (!usesCountMax || usesCountMax === "0")) {
				usesCount = 1;
				usesCountMax = `${usesCount}`;
			}
			// endregion
		}
		// endregion

		// region Saving throws
		if (opts.mode === "player" && entry.entries) {
			DataConverter.WALKER_READONLY_GENERIC.walk(
				entry.entries,
				{
					object: (obj) => {
						if (obj.type !== "abilityDc") return obj;

						if (actionType && saveScaling) return obj;

						actionType = actionType || "save";
						saveScaling = obj.attributes[0]; // Use the first available attribute

						return obj;
					},
					string: (str) => {
						if (actionType && saveAbility && saveScaling) return str;

						// region "8 + pb + mod"
						str.replace(/8\s*\+\s*your proficiency bonus\s*\+\s*your (.*?) modifier/i, (...m) => {
							const customAbilities = [];
							m[1].replace(/(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)/i, (...m2) => {
								customAbilities.push(m2[1].toLowerCase().slice(0, 3));
							});
							if (!customAbilities.length) return;

							actionType = actionType || "save";
							saveScaling = customAbilities[0]; // Use the first available attribute
						});
						// endregion

						// region Spell save
						// e.g.:
						// - "succeed on a Wisdom saving throw against your spell save DC"
						str.replace(/(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw against your (.*? )spell save DC/i, (...m) => {
							actionType = actionType || "save";
							saveAbility = saveAbility || m[1].toLowerCase().slice(0, 3);
							saveScaling = saveScaling || "spell";
						});
						// endregion

						if (opts.modeOptions.isChannelDivinity) {
							str.replace(/(?:make a|succeed on a) (Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw/gi, (...m) => {
								actionType = actionType || "save";
								saveAbility = saveAbility || m[1].toLowerCase().slice(0, 3);
								saveScaling = saveScaling || "spell";
							});
						}

						return str;
					},
				},
			);
		}

		if (opts.mode === "creature" && entry.entries) {
			// If a "passive" text feature has one or more DCs embedded, use the first as the save DC info.
			strEntries.replace(/{@dc ([-+]?\s*\d+)}\s+(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)/, (...m) => {
				const dcNum = Number(m[1].replace(/\s*/g, ""));
				if (isNaN(dcNum)) return;

				actionType = actionType || "save";
				saveAbility = saveAbility || m[2].toLowerCase().slice(0, 3);
				saveDc = dcNum;

				if (opts.pb == null || opts.entity == null || Parser.ABIL_ABVS.some(it => opts.entity[it] == null)) {
					saveScaling = "flat";
					return;
				}

				// If there is exactly one ability score that our saving throw could be based on, use it,
				//   rather than flat scaling.
				const fromAbil = dcNum - opts.pb - 8;
				const abilToBonus = Parser.ABIL_ABVS.map(it => ({ability: it, bonus: Parser.getAbilityModNumber(opts.entity[it])}));
				const matchingAbils = abilToBonus.filter(it => it.bonus === fromAbil);

				if (matchingAbils.length === 1) saveScaling = saveScaling || matchingAbils[0].ability;
				else saveScaling = "flat";
			});
		}
		// endregion

		// region Duration
		if (opts.mode === "player" && entry.entries) {
			DataConverter.WALKER_READONLY_GENERIC.walk(
				entry.entries,
				{
					string: (str) => {
						// Only update the duration if we can set both units and value
						if (durationValue || durationUnits) return;

						// "lasts for 1 minute"
						// "lasts for 8 hours"
						str.replace(/(?:^|\W)lasts for (\d+) (minute|hour|day|month|year|turn|round)s?(?:\W|$)/gi, (...m) => {
							durationValue = Number(m[1]);
							durationUnits = m[2].toLowerCase();
						});

						// "for the next 8 hours"
						str.replace(/(?:^|\W)for the next (\d+) (minute|hour|day|month|year|turn|round)s?(?:\W|$)/gi, (...m) => {
							durationValue = Number(m[1]);
							durationUnits = m[2].toLowerCase();
						});

						// "turned for 1 minute" (e.g. channel divinity)
						str.replace(/(?:^|\W)turned for (\d+) (minute|hour|day|month|year|turn|round)s?(?:\W|$)/gi, (...m) => {
							durationValue = Number(m[1]);
							durationUnits = m[2].toLowerCase();
						});

						// "this effect lasts for 1 minute"
						str.replace(/(?:^|\W)this effect lasts for (\d+) (minute|hour|day|month|year|turn|round)s?(?:\W|$)/gi, (...m) => {
							durationValue = Number(m[1]);
							durationUnits = m[2].toLowerCase();
						});

						// "until the end of your next turn"
						str.replace(/(?:^|\W)until the end of your next turn(?:\W|$)/gi, () => {
							durationValue = 1;
							durationUnits = "turn";
						});

						// "it is charmed by you for 1 minute"
						Renderer.stripTags(str).replace(/(?:^|\W)is \w+ by you for (\d+) (minute|hour|day|month|year|turn|round)s(?:\W|$)/gi, (...m) => {
							durationValue = Number(m[1]);
							durationUnits = m[2].toLowerCase();
						});
					},
				},
			);
		}
		// endregion

		// region Resource Consumption
		if (opts.mode === "player" && entry.entries) {
			DataConverter.WALKER_READONLY_GENERIC.walk(
				entry.entries,
				{
					string: (str) => {
						if (consumeType || consumeTarget || consumeAmount) return;

						str.replace(/(?:(\d+) to )?(\d+) (?:ki|sorcery) point/i, (...m) => {
							consumeType = "attribute";
							consumeTarget = "resources.primary.value";
							consumeAmount = Number(m[1] || m[2]);
						});
					},
				},
			);
		}
		// endregion

		// region Damage
		let strEntriesNoDamageDice = strEntries;
		if ((opts.mode === "player" || opts.mode === "vehicle") && entry.entries && !damageParts.length) {
			const {str, damageTupleMetas} = DataConverter.getDamageTupleMetas(strEntries, {summonSpellLevel: opts.summonSpellLevel});
			strEntriesNoDamageDice = str;

			const {damageParts: damageParts_, formula: formula_} = DataConverter.getDamagePartsAndOtherFormula(damageTupleMetas);

			damageParts = damageParts_;
			formula = formula || formula_;
		}
		// endregion

		// region Other Formulas
		if ((opts.mode === "player" || opts.mode === "vehicle") && entry.entries && !formula) {
			// Note: not `[g]lobal`, as only one "other formula" is supported
			strEntriesNoDamageDice.replace(/{(?:@dice|@scaledice) ([^}]+)}/i, (...m) => {
				const [dice] = m[1].split("|");
				formula = dice;
			});
		}
		// endregion

		// region Target
		if (strEntries) {
			strEntries
				// Dragon breath weapons; abominable yeti breath weapon
				.replace(/exhales [^.]*a (\d+)-foot[- ](cone|line)/, (...m) => {
					targetValue = targetValue || Number(m[1]);
					targetUnits = targetUnits || "ft";
					targetType = targetType || m[2]; // "cone" and "line" are both valid values
				});
		}
		// endregion

		// region Post-processing
		try { activationCondition = Renderer.stripTags(activationCondition); } catch (e) { console.error(...LGT, e); }
		// endregion

		name = name.trim().replace(/\s+/g, " ");
		if (!name) name = "(Unnamed)"; // Empty names are invalid in Foundry

		return {
			name: name,
			type: opts.fvttType,
			data: {
				source: opts.source ? Parser.sourceJsonToAbv(opts.source) : "",
				description: {value: description, chat: "", unidentified: ""},

				damage: {
					parts: damageParts,
					versatile: "",
				},
				duration: {
					value: durationValue,
					units: durationUnits,
				},
				range: {value: null, long: null, units: ""},
				requirements,

				save: {
					ability: saveAbility,
					dc: saveDc,
					scaling: saveScaling || "flat",
				},

				activation: {
					type: activationType,
					cost: activationCost,
					condition: activationCondition,
				},

				target: {
					value: targetValue,
					units: targetUnits,
					type: targetType,
				},

				uses: {
					value: usesCount,
					max: usesCountMax,
					per: usesPer,
				},
				ability: "",
				actionType: actionType || "other",
				attackBonus: attackBonus,
				chatFlavor: "",
				critical: null,

				formula,

				recharge: {
					value: rechargeOn,
					charged: rechargeOn != null,
				},

				consume: {
					type: consumeType,
					target: consumeTarget,
					amount: consumeAmount,
				},

				...(combinedFoundryData || {}),
				...(opts.additionalData || {}),
			},
			img: opts.img,
			flags: {
				...(UtilCompat.getFeatureFlags({isReaction: activationType === "reaction"})),
				...(combinedFoundryFlags || {}),
			},
			effects: opts.effects || [],
		};
	}

	/**
	 * @param data Array of adventure/book data
	 * @param indexData The matching metadata data from the adventure/book index
	 * @param prop Either "adventure" or "book"
	 * @param [opts] Options object.
	 * @param [opts.isAddPermission]
	 */
	static _getAdventureBookJournals (data, indexData, prop, opts) {
		const configGroup = prop === "adventure" ? "importAdventure" : "importBook";

		const out = [];
		const contents = indexData.contents;

		const len = Math.min(contents.length, data.length);
		for (let i = 0; i < len; ++i) {
			const content = contents[i];
			const chapter = data[i];

			const contentRendered = DataConverter.getWithDescriptionPlugins(() => {
				return `<div class="w-100 overflow-x-hidden">${Renderer.get().setFirstSection(true).render(chapter)}</div>`.replace(new RegExp(DataConverter.SYM_AT, "g"), "@");
			});

			const journalItem = {
				name: UtilApplications.getCleanEntityName(`${this._getAdventureBookJournalPrefix(configGroup, indexData.source, i, len)}${Parser.bookOrdinalToAbv(content.ordinal)}${content.name}`),
				permission: {default: 0},
				entryTime: Date.now(),
				content: contentRendered,
				// Try to keep our entries together at all costs
				sort: CONFIG.JournalEntry.collection.instance.contents.length + (CONST.SORT_INTEGER_DENSITY * i),
			};

			if (opts.isAddPermission) journalItem.permission = {default: Config.get(configGroup, "permissions")};

			out.push(journalItem);
		}

		return out;
	}

	static _getAdventureBookJournalPrefix (configGroup, source, ix, ixMax) {
		if (!Config.get(configGroup, "isOrderingPrefixJournalNames")) return "";
		const numPads = `${ixMax}`.length;
		return `${Parser.sourceJsonToAbv(source).slice(0, 3)}${`${ix}`.padStart(numPads, "0")} `;
	}

	static getAdventureJournals (data, indexData, opts) {
		return this._getAdventureBookJournals(data, indexData, "adventure", opts);
	}

	static getBookJournals (data, indexData, opts) {
		return this._getAdventureBookJournals(data, indexData, "book", opts);
	}

	/**
	 * @param existingProfs Existing skill data.
	 * @param skillProficiencies Data from a background or race.
	 * @param actorData Actor update object to fill.
	 * @param importOpts
	 * @param [opts] Options
	 * @param [opts.titlePrefix]
	 * @return {Promise<object>}
	 */
	static async pFillActorSkillData (existingProfs, skillProficiencies, actorData, importOpts, opts) {
		opts = opts || {};

		if (!skillProficiencies) return {};

		const formData = await Charactermancer_SkillProficiencySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				skillProficiencies: existingProfs,
			},
			available: skillProficiencies,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		return this.doApplySkillFormDataToActorUpdate({existingProfs, formData, actorData});
	}

	static doApplySkillFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		if (!formData?.data?.skillProficiencies) return;

		const out = {};

		// TODO this currently discards tool selections
		// 0 = not, 1 = proficient, 2 = expert, 0.5 = jack of all trades
		actorData.skills = actorData.skills || {};
		Object.entries(UtilActors.SKILL_ABV_TO_FULL)
			.filter(([_, name]) => formData.data.skillProficiencies[name])
			.forEach(([abv, name]) => {
				out[abv] = formData.data.skillProficiencies[name];

				const maxValue = Math.max(
					(existingProfs[abv] || {}).value || 0,
					formData.data.skillProficiencies[name] != null ? Number(formData.data.skillProficiencies[name]) : 0,
					(actorData.skills[abv] || {}).value || 0,
				);

				const isUpdate = maxValue > (MiscUtil.get(actorData.skills, abv, "value") || 0);
				if (isUpdate) (actorData.skills[abv] = actorData.skills[abv] || {}).value = maxValue;
			});

		return out;
	}

	/**
	 * @param existingProfs Actor's current languages.
	 * @param importingProfs Data from a background or race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts] Options
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorLanguageData (existingProfs, importingProfs, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_LanguageProficiencySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				languageProficiencies: existingProfs,
			},
			available: importingProfs,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyLanguageProficienciesFormDataToActorUpdate({existingProfs, formData, actorData: data});
	}

	static doApplyLanguageProficienciesFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "languageProficiencies",
			actorData,
			opts: {
				fnGetMappedItem: it => UtilActors.getMappedLanguage(it),
				actorTraitProp: "languages",
			},
		});
	}

	/**
	 * @param existingProfs Actor's current tool proficiencies.
	 * @param importingProfs Data from a background or race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts] Options
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorToolProfData (existingProfs, importingProfs, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_ToolProficiencySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				toolProficiencies: existingProfs,
			},
			available: importingProfs,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyToolProficienciesFormDataToActorUpdate({existingProfs, formData, actorData: data});
	}

	static doApplyToolProficienciesFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "toolProficiencies",
			actorData,
			opts: {
				fnGetMappedItem: it => UtilActors.getMappedTool(it),
				actorTraitProp: "toolProf",
			},
		});
	}

	/**
	 * @param existingProfsLanguages Actor's current languages.
	 * @param existingProfsTools Actor's current tool proficiencies.
	 * @param importingProfs Data from a background or race.
	 * @param actorData Actor update object to fill.
	 * @param importOpts
	 * @param [opts] Options
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorLanguageOrToolData (existingProfsLanguages, existingProfsTools, importingProfs, actorData, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_LanguageToolProficiencySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				languageProficiencies: existingProfsLanguages,
				toolProficiencies: existingProfsTools,
			},
			available: importingProfs,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfsLanguages,
			formData,
			formDataProp: "languageProficiencies",
			actorData,
			opts: {
				fnGetMappedItem: it => UtilActors.getMappedLanguage(it),
				actorTraitProp: "languages",
			},
		});

		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfsTools,
			formData,
			formDataProp: "toolProficiencies",
			actorData,
			opts: {
				fnGetMappedItem: it => UtilActors.getMappedTool(it),
				actorTraitProp: "toolProf",
			},
		});
	}

	/**
	 * @param existingProfs Actor's current tool proficiencies.
	 * @param importingProfs Data from a background or race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts] Options
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorArmorProfData (existingProfs, importingProfs, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_ArmorProficiencySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				armorProficiencies: existingProfs,
			},
			available: importingProfs,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyArmorProficienciesFormDataToActorUpdate({existingProfs, formData, actorData: data});
	}

	static doApplyArmorProficienciesFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "armorProficiencies",
			actorData,
			opts: {
				fnGetMappedItem: it => {
					if (!it) return null;
					if (/^shield\|phb\b/i.test(it)) return UtilActors.getMappedArmorProficiency("shields");
					return UtilActors.getMappedArmorProficiency(it)
				},
				fnGetMappedCustomItem: it => Renderer.splitTagByPipe(it)[0].toTitleCase(),
				actorTraitProp: "armorProf",
			},
		});
	}

	/**
	 * @param existingProfs Actor's current tool proficiencies.
	 * @param importingProfs Data from a background or race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts] Options
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorWeaponProfData (existingProfs, importingProfs, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_WeaponProficiencySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				weaponProficiencies: existingProfs,
			},
			available: importingProfs,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyWeaponProficienciesFormDataToActorUpdate({existingProfs, formData, actorData: data});
	}

	static doApplyWeaponProficienciesFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "weaponProficiencies",
			actorData,
			opts: {
				fnGetMappedItem: it => UtilActors.getMappedWeaponProficiency(it),
				fnGetMappedCustomItem: it => Renderer.splitTagByPipe(it)[0].toTitleCase(),
				actorTraitProp: "weaponProf",
			},
		});
	}

	/**
	 * @param existingProfsActor Actor's current proficiencies.
	 * @param formData Data from a background or race.
	 * @param formDataProp Property within `formData` to use.
	 * @param actorData Actor update object to fill.
	 * @param opts Options object
	 * @param [opts.fnGetMappedItem]
	 * @param [opts.fnGetMappedCustomItem]
	 * @param opts.actorTraitProp
	 */
	static doApplyOtherProficienciesFormData ({existingProfsActor, formData, formDataProp, actorData, opts}) {
		if (!formData?.data?.[formDataProp]) return false;

		existingProfsActor = existingProfsActor || {};

		const formDataSet = formData.data[formDataProp];

		if (!Object.keys(formDataSet).length) return false;
		const cpyFormDataSet = MiscUtil.copy(formDataSet);

		const profSet = new Set();
		Object.keys(cpyFormDataSet).filter(k => cpyFormDataSet[k]).forEach(k => profSet.add(k));

		// Map known items to their respective Foundry values; keep invalid items as-is
		const mappedValidItems = new Set();
		const customItems = [];

		// Keep existing items
		(existingProfsActor.value || []).forEach(it => mappedValidItems.add(it));
		(existingProfsActor.custom || "").split(";").map(it => it.trim()).filter(Boolean).forEach(it => this._doApplyFormData_doCheckAddCustomItem(customItems, it));

		// Keep items already in the actor update
		const existingProfsActorData = MiscUtil.get(actorData, "traits", opts.actorTraitProp);
		(existingProfsActorData?.value || []).forEach(it => mappedValidItems.add(it));
		(existingProfsActorData?.custom || "").split(";").map(it => it.trim()).filter(Boolean).forEach(it => this._doApplyFormData_doCheckAddCustomItem(customItems, it));

		// Add new items
		profSet.forEach(it => {
			const mapped = opts.fnGetMappedItem ? opts.fnGetMappedItem(it) : it;
			if (mapped) mappedValidItems.add(mapped);
			else {
				const toAdd = opts.fnGetMappedCustomItem ? opts.fnGetMappedCustomItem(it) : it.toTitleCase();
				this._doApplyFormData_doCheckAddCustomItem(customItems, toAdd);
			}
		});

		// Set data
		const dataTarget = MiscUtil.set(actorData, "traits", opts.actorTraitProp, {});
		dataTarget.value = [...mappedValidItems].map(it => it.toLowerCase()).sort(SortUtil.ascSortLower);
		dataTarget.custom = customItems.join(";");
	}

	static _doApplyFormData_doCheckAddCustomItem (customItems, item) {
		const cleanItem = item.trim().toLowerCase();
		if (!customItems.some(it => it.trim().toLowerCase() === cleanItem)) customItems.push(item);
	}

	static doApplySavingThrowProficienciesFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		if (!formData?.data?.savingThrowProficiencies) return;

		actorData.abilities = actorData.abilities || {};
		Parser.ABIL_ABVS
			.filter(ab => formData.data.savingThrowProficiencies[ab])
			.forEach(ab => {
				const maxValue = Math.max(
					existingProfs[ab]?.proficient || 0,
					formData.data.savingThrowProficiencies[ab] ? 1 : 0,
					actorData.abilities[ab]?.proficient || 0,
				);
				const isUpdate = maxValue > (MiscUtil.get(actorData.abilities, ab, "proficient") || 0);
				if (isUpdate) MiscUtil.set(actorData.abilities, ab, "proficient", maxValue);
			});
	}

	/**
	 * @param existing Actor's current damage immunities.
	 * @param [importing] Custom data from a race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts]
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorImmunityData (existing, importing, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_DamageImmunitySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				immune: existing,
			},
			available: importing,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyDamageImmunityFormDataToActorUpdate({existingProfs: existing, formData, actorData: data});
	}

	/**
	 * @param existing Actor's current damage resistances.
	 * @param [importing] Custom data from a race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts]
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorResistanceData (existing, importing, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_DamageResistanceSelect.pGetUserInput({
			...opts,
			existingFvtt: {
				resist: existing,
			},
			available: importing,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyDamageResistanceFormDataToActorUpdate({existingProfs: existing, formData, actorData: data});
	}

	/**
	 * @param existing Actor's current damage vulnerabilities.
	 * @param [importing] Custom data from a race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts]
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorVulnerabilityData (existing, importing, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_DamageVulnerabilitySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				vulnerable: existing,
			},
			available: importing,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyDamageVulnerabilityFormDataToActorUpdate({existingProfs: existing, formData, actorData: data});
	}

	/**
	 * @param existing Actor's current condition immunities.
	 * @param [importing] Custom data from a race.
	 * @param data Actor update object to fill.
	 * @param importOpts
	 * @param [opts]
	 * @param [opts.titlePrefix]
	 */
	static async pFillActorConditionImmunityData (existing, importing, data, importOpts, opts) {
		opts = opts || {};

		const formData = await Charactermancer_ConditionImmunitySelect.pGetUserInput({
			...opts,
			existingFvtt: {
				conditionImmune: existing,
			},
			available: importing,
		});
		if (!formData) return importOpts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		this.doApplyConditionImmunityFormDataToActorUpdate({existingProfs: existing, formData, actorData: data});
	}

	static doApplyDamageImmunityFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "immune",
			actorData,
			opts: {
				actorTraitProp: "di",
			},
		});
	}

	static doApplyDamageResistanceFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "resist",
			actorData,
			opts: {
				actorTraitProp: "dr",
			},
		});
	}

	static doApplyDamageVulnerabilityFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "vulnerable",
			actorData,
			opts: {
				actorTraitProp: "dv",
			},
		});
	}

	static doApplyConditionImmunityFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs,
			formData,
			formDataProp: "conditionImmune",
			actorData,
			opts: {
				fnGetMappedItem: it => it === "disease" ? "diseased" : it,
				actorTraitProp: "ci",
			},
		});
	}

	static doApplyExpertiseFormDataToActorUpdate ({existingProfs, formData, actorData}) {
		this.doApplySkillFormDataToActorUpdate({
			existingProfs: existingProfs.skillProficiencies,
			formData,
			actorData,
		});

		this.doApplyOtherProficienciesFormData({
			existingProfsActor: existingProfs.toolProficiencies,
			formData,
			formDataProp: "toolProficiencies",
			actorData,
			opts: {
				// FIXME(Future) As of 2021-06-13 Foundry does not support tool expertise in a useful way (it can only be
				//   defined on a tool item, rather than character-wide), so work around this by using custom values.
				fnGetMappedItem: it => {
					if (formData?.data?.toolProficiencies[it] === 2) return null;
					return UtilActors.getMappedTool(it);
				},
				fnGetMappedCustomItem: it => {
					if (formData?.data?.toolProficiencies[it] === 2) return `${it.toTitleCase()} (Expertise)`
					return it.toTitleCase();
				},
				actorTraitProp: "toolProf",
			},
		});
	}

	static getDamageTupleMetas (str, {summonSpellLevel = 0} = {}) {
		const damageTupleMetas = [];

		const ixFirstDc = str.indexOf(`{@dc `);

		// number or roll; will be a number for e.g. "1 piercing damage"
		// "the spell's level" part for e.g. TCE's Aberrant Spirit
		// "plus ..." part for e.g. Orc War Chief
		const strOut = str.replace(/(?:(\d+)|\(?{@(?:dice|damage) ([^}]+)}(?:\s+[-+]\s+the spell's level)?(?: plus {@(?:dice|damage) ([^}]+)})?\)?)(?:\s+[-+]\s+[-+a-zA-Z0-9 ]*?)? ([^ ]+) damage/gi, (...mDamage) => {
			const [, dmgFlat, dmgDice1, dmgDice2, dmgType, ixMatch] = mDamage

			const isFlatDamage = dmgFlat != null;
			let dmg = isFlatDamage
				? dmgFlat
				: dmgDice2
					? `${dmgDice1} + ${dmgDice2}` : dmgDice1;

			// Ensure the flat damage is not a condition, e.g. CoS Tree Blight "by dealing 6 slashing damage [to the blight]"
			if (isFlatDamage) {
				const tokens = str.split(/( )/g);
				let lenTokenStack = 0;
				const tokenStack = [];
				for (let i = 0; i < tokens.length; ++i) {
					tokenStack.push(tokens[i]);
					lenTokenStack += tokens[i].length;

					if (lenTokenStack === ixMatch) {
						const lastFourTokens = tokenStack.slice(-4);
						if (/^by dealing$/i.test(lastFourTokens.join("").trim())) {
							return "";
						}
					}
				}
			}

			// Switch out proficiency bonus
			dmg = dmg.replace(/\bPB\b/gi, "@srd5e.userchar.pb");

			// Switch out `summonSpellLevel`
			dmg = dmg.replace(/\bsummonSpellLevel\b/gi, `${summonSpellLevel ?? 0}`);

			const tupleMeta = {
				tuple: [dmg, dmgType],
			};

			// If this damage appears to be from failing a DC, mark it as such
			if (~ixFirstDc && ixMatch >= ixFirstDc) {
				tupleMeta.isOnFailSavingThrow = true;
			}

			damageTupleMetas.push(tupleMeta);

			return "";
		});

		return {
			str: strOut, // Return this string without damage parts
			damageTupleMetas: damageTupleMetas.filter(it => it.tuple.length),
		};
	}

	static getDamagePartsAndOtherFormula (damageTupleMetas) {
		damageTupleMetas = damageTupleMetas || [];

		if (!Config.get("import", "isUseOtherFormulaFieldForSaveHalvesDamage")) return {damageParts: damageTupleMetas.map(it => it.tuple), formula: ""};

		const damageTuples = [];
		let otherFormula = ``;

		damageTupleMetas.forEach(meta => {
			if (otherFormula || !meta.isOnFailSavingThrow) return damageTuples.push(meta.tuple);

			otherFormula = `${meta.tuple[0]}${meta.tuple[1] ? `[${meta.tuple[1]}]` : ""}`
		});

		// If the "save halves" damage is not a rider on some other damage, return generic damage
		if (!damageTuples.length) return {damageParts: damageTupleMetas.map(it => it.tuple), formula: ""};

		return {damageParts: damageTuples, formula: otherFormula};
	}

	static getSpeedValue (speed, prop) {
		if (typeof speed === "number") return prop === "walk" ? speed : null;
		speed = speed[prop];
		if (speed == null) return null;
		if (speed.number != null) return speed.number;
		return Number(speed);
	}

	static isSpeedHover (speed) {
		if (typeof speed === "number") return false;
		return !!speed.canHover;
	}

	static getMovement (speed) {
		return {
			burrow: DataConverter.getSpeedValue(speed, "burrow"),
			climb: DataConverter.getSpeedValue(speed, "climb"),
			fly: DataConverter.getSpeedValue(speed, "fly"),
			swim: DataConverter.getSpeedValue(speed, "swim"),
			walk: DataConverter.getSpeedValue(speed, "walk"),
			units: "ft",
			hover: DataConverter.isSpeedHover(speed),
		};
	}

	static getAttackRange (str) {
		let rangeShort = 0;
		let rangeLong = 0;

		// Prioritise range first, as you can throw a melee weapon, but you Can't:tm: melee with a ranged weapon
		const mRange = /range (\d+)\/(\d+) ft/gi.exec(str);
		if (mRange) {
			rangeShort = Number(mRange[1]);
			rangeLong = Number(mRange[2])
		} else {
			const mReach = /reach (\d+) ft/gi.exec(str);
			if (mReach) {
				rangeShort = Number(mReach[1]);
			}
		}

		return {rangeShort, rangeLong};
	}

	static getAttackActionType (str) {
		let actionType = "";
		let isAttack = false;

		const mAtk = /{@atk ([^}]+)}/gi.exec(str);
		if (mAtk) {
			isAttack = true;

			if (mAtk[1].includes("s")) {
				if (mAtk[1].includes("m")) actionType = "msak";
				else actionType = "rsak";
			} else if (mAtk[1].includes("m")) actionType = "mwak";
			else if (mAtk[1].includes("r")) actionType = "rwak";
		}

		return {actionType, isAttack};
	}

	static getCleanOriginalData (data) {
		data = MiscUtil.copy(data);

		// Delete temp filter data
		Object.keys(data)
			.filter(k => /^_f[A-Z]/.test(k))
			.forEach(k => delete data[k]);

		this._getCleanOriginalData_recurse(data);

		return data;
	}

	static _getCleanOriginalData_recurse (obj) {
		if (obj == null) return;
		if (typeof obj !== "object") return;

		if (obj instanceof Array) {
			obj.forEach(it => this._getCleanOriginalData_recurse(it));
		} else {
			Object.entries(obj).forEach(([k, v]) => {
				// Foundry data objects cannot have keys starting with "$"
				if (k.includes("$")) {
					delete obj[k];
					console.warn(...LGT, `Removed key "${k}" from object`);
				} else this._getCleanOriginalData_recurse(v);
			});
		}
	}

	static getActorDamageResImmVulnConditionImm (ent) {
		const out = {};

		const allDis = new Set();
		let customDis = [];
		this._getActorDamageResImmVulnConditionImm_addDamageTypesOrConditionTypes({
			ent,
			validTypesArr: UtilActors.VALID_DAMAGE_TYPES,
			fnRender: Parser.getFullImmRes,
			prop: "immune",
			allSet: allDis,
			customStack: customDis,
		});

		out.di = {
			value: [...allDis],
			custom: customDis.join(", "),
		};

		const allDrs = new Set();
		let customDrs = [];
		this._getActorDamageResImmVulnConditionImm_addDamageTypesOrConditionTypes({
			ent,
			validTypesArr: UtilActors.VALID_DAMAGE_TYPES,
			fnRender: Parser.getFullImmRes,
			prop: "resist",
			allSet: allDrs,
			customStack: customDrs,
		});

		out.dr = {
			value: [...allDrs],
			custom: customDrs.join(", "),
		};

		const allDvs = new Set();
		let customDvs = [];
		this._getActorDamageResImmVulnConditionImm_addDamageTypesOrConditionTypes({
			ent,
			validTypesArr: UtilActors.VALID_DAMAGE_TYPES,
			fnRender: Parser.getFullImmRes,
			prop: "vulnerable",
			allSet: allDvs,
			customStack: customDvs,
		});

		out.dv = {
			value: [...allDvs],
			custom: customDvs.join(", "),
		};

		const allCis = new Set();
		let customCis = [];
		this._getActorDamageResImmVulnConditionImm_addDamageTypesOrConditionTypes({
			ent,
			validTypesArr: UtilActors.VALID_CONDITIONS,
			fnRender: arr => Parser.getFullCondImm(arr, true),
			prop: "conditionImmune",
			allSet: allCis,
			customStack: customCis,
		});

		out.ci = {
			value: [...allCis],
			custom: customCis.join(", "),
		};

		return out;
	}

	static _getActorDamageResImmVulnConditionImm_addDamageTypesOrConditionTypes (
		{ent, validTypesArr, fnRender, prop, allSet, customStack},
	) {
		if (!ent[prop]) return;

		ent[prop].forEach(it => {
			if (validTypesArr.includes(it)) allSet.add(it);
			else if (this._PROPS_DAMAGE_IMM_VULN_RES.has(prop) && it[prop] && it[prop] instanceof Array
				&& CollectionUtil.setEq(new Set(it[prop]), this._SET_PHYSICAL_DAMAGE) && it.note && it.cond) {
				allSet.add("physical");
			} else {
				const asText = fnRender([it]);
				allSet.add("custom");
				customStack.push(asText);
			}
		});
	}

	static async pGetSideData_ (ent, {propBrew, fnLoadJson, propJson, fnMatch}) {
		let found = (MiscUtil.get(BrewUtil, "homebrew", propBrew) || [])
			.find(it => (fnMatch || this._pGetAdditional_fnMatch)(ent, it));

		if (!found) {
			const additionalData = await fnLoadJson();
			found = (additionalData[propJson] || [])
				.find(it => (fnMatch || this._pGetAdditional_fnMatch)(ent, it));
		}

		return found;
	}

	static _pGetAdditional_fnMatch (entity, additionalDataEntity) {
		return entity.name === additionalDataEntity.name && entity.source === additionalDataEntity.source;
	}

	static async pGetAdditionalData_ (ent, {propBrew, fnLoadJson, propJson, fnMatch}) {
		return this._pGetAdditionalStar(ent, {propBrew, fnLoadJson, propJson, fnMatch, propFromEntity: "foundryData", propFromSideLoaded: "data"});
	}

	static async pGetAdditionalEffectsRaw_ (ent, {propBrew, fnLoadJson, propJson, fnMatch}) {
		return this._pGetAdditionalStar(ent, {propBrew, fnLoadJson, propJson, fnMatch, propFromEntity: "foundryEffects", propFromSideLoaded: "effects"});
	}

	static async _pGetAdditionalStar (ent, {propBrew, fnLoadJson, propJson, fnMatch, propFromEntity, propFromSideLoaded}) {
		const found = await this.pGetSideData_(ent, {propBrew, fnLoadJson, propJson, fnMatch});
		return this.pGetAdditionalStarFromFound_(ent, {propFromEntity, propFromSideLoaded, found});
	}

	static async pGetAdditionalStarFromFound_ (ent, {propFromEntity, propFromSideLoaded, found}) {
		const fromEntity = ent[propFromEntity];

		if ((!found || !found[propFromSideLoaded]) && !fromEntity) return null;

		// Allow the `foundryData` property on the entity to override/compliment anything defined as separate data
		const out = MiscUtil.copy(found ? found[propFromSideLoaded] : fromEntity);
		if (found && fromEntity) {
			if (out instanceof Array) out.push(...MiscUtil.copy(fromEntity)); // Array merge strategy, for effects
			else Object.assign(out, MiscUtil.copy(fromEntity)); // Object merge strategy, for data
		}

		return out;
	}

	static async pGetItemWeaponType (uid) {
		uid = uid.toLowerCase().trim();

		if (UtilActors.WEAPONS_MARTIAL.includes(uid)) return "martial";
		if (UtilActors.WEAPONS_SIMPLE.includes(uid)) return "simple";

		let [name, source] = Renderer.splitTagByPipe(uid);
		source = source || "phb";
		const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_ITEMS]({name, source});

		if (Renderer.hover.isCached(UrlUtil.PG_ITEMS, source, hash)) return Renderer.hover.getFromCache(UrlUtil.PG_ITEMS, source, hash)?.weaponCategory;

		// If the item *probably* isn't available, return null. This prevents us from re-running the cache if we're
		//   looking for e.g. a string which isn't an item UID.
		if (Renderer.hover.isPageSourceCached(UrlUtil.PG_ITEMS, source)) return null;

		// If we've yet to attempt to load this source, load the item, and hopefully return the type
		const found = await Renderer.hover.pCacheAndGet(UrlUtil.PG_ITEMS, source, hash);
		return found?.weaponCategory;
	}

	static getImportedEmbed (importedEmbeds, itemData) {
		const importedEmbed = importedEmbeds.find(it => it.raw === itemData);

		if (!importedEmbed) {
			ui.notifications.warn(`Failed to link embedded entity for active effects! ${VeCt.STR_SEE_CONSOLE}`);
			console.warn(...LGT, `Could not find loaded item data`, itemData, `in imported embedded entities`, importedEmbeds);
			return null;
		}

		return importedEmbed;
	}
}
DataConverter.WALKER_READONLY_GENERIC = MiscUtil.getWalker({isNoModification: true, keyBlacklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLACKLIST});
DataConverter.WALKER_GENERIC = MiscUtil.getWalker({keyBlacklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLACKLIST});

DataConverter.SYM_AT = "<PLUT_SYM__AT>"

DataConverter._SOURCE_PAGE_PREFIX = " pg. ";
DataConverter._SOURCE_PAGE_PREFIX_RE = new RegExp(`${DataConverter._SOURCE_PAGE_PREFIX}\\d+`);

DataConverter.ITEM_TYPES_ACTOR_TOOLS = new Set(["AT", "GS", "INS", "T"]);
DataConverter.ITEM_TYPES_ACTOR_WEAPONS = new Set(["M", "R"]);
DataConverter.ITEM_TYPES_ACTOR_ARMOR = new Set(["LA", "MA", "HA", "S"]);

DataConverter._PROPS_DAMAGE_IMM_VULN_RES = new Set(["immune", "resist", "vulnerable"]);
DataConverter._SET_PHYSICAL_DAMAGE = new Set(["bludgeoning", "piercing", "slashing"]);

export {DataConverter};
