import {UtilActors} from "./UtilActors.js";
import {DataConverter} from "./DataConverter.js";
import {ModalFilterItemsFvtt} from "./UtilModalFilter.js";
import {UtilApplications} from "./UtilApplications.js";

class Charactermancer_Util {
	static getCurrentLevel (actor) {
		return actor.items.filter(it => it.type === "class").map(it => Number(it.data.data.levels || 0)).sum();
	}

	static getBaseAbilityScores (actor) { return this._getAbilityScores(actor, true); }

	static getCurrentAbilityScores (actor) { return this._getAbilityScores(actor, false); }

	static _getAbilityScores (actor, isBase) {
		const actorData = isBase ? (actor.data._source || actor.data) : actor.data;
		const out = {
			str: Number(MiscUtil.get(actorData, "data", "abilities", "str", "value") || 0),
			dex: Number(MiscUtil.get(actorData, "data", "abilities", "dex", "value") || 0),
			con: Number(MiscUtil.get(actorData, "data", "abilities", "con", "value") || 0),
			int: Number(MiscUtil.get(actorData, "data", "abilities", "int", "value") || 0),
			wis: Number(MiscUtil.get(actorData, "data", "abilities", "wis", "value") || 0),
			cha: Number(MiscUtil.get(actorData, "data", "abilities", "cha", "value") || 0),
		};
		Object.entries(out)
			.forEach(([abv, val]) => {
				if (isNaN(val)) out[abv] = 0;
			});
		return out;
	}

	static getBaseHp (actor) {
		return this._getHp(actor, true);
	}

	static _getHp (actor, isBase) {
		const actorData = isBase ? (actor.data._source || actor.data) : actor.data;
		return {
			value: (actorData?.data?.attributes?.hp?.value || 0),
			max: (actorData?.data?.attributes?.hp?.max || 0),
		};
	}

	static getAttackAbilityScore (itemAttack, abilityScores, mode) {
		if (!itemAttack || !abilityScores) return null;
		switch (mode) {
			case "melee": {
				const isFinesse = !!MiscUtil.get(itemAttack, "data", "data", "properties", "fin");
				if (!isFinesse) return abilityScores.str;
				return abilityScores.str > abilityScores.dex ? abilityScores.str : abilityScores.dex;
			}
			case "ranged": {
				const isThrown = !!MiscUtil.get(itemAttack, "data", "data", "properties", "thr");
				if (!isThrown) return abilityScores.dex;
				return abilityScores.str > abilityScores.dex ? abilityScores.str : abilityScores.dex;
			}
			default: throw new Error(`Unhandled mode "${mode}"`);
		}
	}

	/**
	 * @param opts
	 * @param opts.casterProgression The class spellcasting progression
	 * @param opts.curLevel The current actor level (zero is valid)
	 * @param opts.targetLevel The target actor level
	 * @param [opts.isBreakpointsOnly] If class levels between jumps in spell level should be ignored.
	 */
	static getCasterProgressionMeta ({casterProgression, curLevel, targetLevel, isBreakpointsOnly = false}) {
		if (casterProgression == null || curLevel == null || targetLevel == null) return null;

		const progression = UtilActors.CASTER_TYPE_TO_PROGRESSION[casterProgression];
		if (!progression) return null;

		const levelToSpellLevel = [];
		let lastSpellLevel = 0;
		progression.forEach(slots => {
			let isFound = false;
			for (let i = 0; i < slots.length; ++i) {
				const spellLevel = i + 1;
				if (slots[i] && spellLevel > lastSpellLevel) {
					levelToSpellLevel.push(spellLevel);
					lastSpellLevel = spellLevel;
					isFound = true;
					break;
				}
			}
			if (!isFound) {
				if (isBreakpointsOnly) levelToSpellLevel.push(null);
				// Copy the last spell level to form a contiguous spell level array
				else levelToSpellLevel.push(levelToSpellLevel.length ? levelToSpellLevel.last() : null);
			}
		});

		const spannedLevels = levelToSpellLevel.slice(curLevel, targetLevel).filter(Boolean);
		if (!spannedLevels.length) return null;

		const spellLevelLow = Math.min(...spannedLevels);
		const spellLevelHigh = Math.max(...spannedLevels);

		const deltaLevels = Math.max(0, targetLevel - curLevel);
		const deltaSpellLevels = spellLevelHigh - spellLevelLow;

		return {
			spellLevelLow,
			spellLevelHigh,
			deltaLevels,
			deltaSpellLevels,
		};
	}
}
Charactermancer_Util.STR_WARN_SOURCE_SELECTION = `Did you change your source selection since using the Charactermancer initially?`;

class Charactermancer_FeatureSourceTracker extends BaseComponent {
	constructor () {
		super();
		this._registered = new Map();
	}

	register (comp) {
		this._registered.set(comp, {state: null, hookMetas: []});
	}

	_validateProp (propPulse) {
		if (!Charactermancer_FeatureSourceTracker._VALID_HOOK_PROPS.has(propPulse)) throw new Error(`Unhandled pulse prop "${propPulse}"`);
	}

	addHook (comp, propPulse, hk) {
		this._validateProp(propPulse);

		if (!this._registered.has(comp)) this.register(comp);

		this._registered.get(comp).hookMetas.push({propPulse, hook: hk});
		this._addHookBase(propPulse, hk);
	}

	removeHook (comp, propPulse, hk) {
		this._validateProp(propPulse);

		if (!this._registered.has(comp)) return;

		const compMeta = this._registered.get(comp);
		const ixHook = compMeta.hookMetas.findIndex(it => it.hook === hk);
		if (~ixHook) compMeta.hookMetas.splice(ixHook, 1);
		this._removeHookBase(propPulse, hk);
	}

	/** A component can call this, passing in a generalised form of its state, to have the tracker track it. */
	setState (comp, state) {
		if (!this._registered.has(comp)) this.register(comp);

		const compMeta = this._registered.get(comp);

		const prevState = compMeta.state ? MiscUtil.copy(compMeta.state) : compMeta.state;
		compMeta.state = state;

		const allKeys = new Set([
			...Object.keys(prevState || {}),
			...Object.keys(state || {}),
		]);

		// For each piece of state, pulse if it changed
		allKeys
			.forEach(k => {
				const oldVal = prevState?.[k];
				const nuVal = state?.[k];

				if (CollectionUtil.deepEquals(oldVal, nuVal)) return;

				this._doPulseForProp(k);
			});
	}

	/**
	 * @param key
	 * @param ignore A component who's state should be ignored (usually the component calling this method)
	 */
	getStatesForKey (key, {ignore = null} = {}) {
		const out = [];
		for (const [comp, compMeta] of this._registered.entries()) {
			if (ignore === comp) continue;
			if (compMeta?.state?.[key]) out.push(compMeta.state[key]);
		}
		return out;
	}

	/** When a component is e.g. removed from the UI, it should call this to ensure its state is no longer tracked. */
	unregister (comp) {
		if (!comp) return;

		const registered = this._registered.get(comp);
		if (!registered) return;

		// Clean up state
		this._registered.delete(comp);

		// Clean up hooks
		registered.hookMetas.forEach(({propPulse, hook}) => {
			this._removeHookBase(propPulse, hook);
		});

		// Notify other components that state has changed
		if (registered.state) {
			Object.keys(registered.state)
				.forEach(k => { // a key is e.g. "skills"
					this._doPulseForProp(k);
				})
		}
	}

	_doPulseForProp (k) {
		switch (k) {
			case "skills": return this._state.pulseSkills = !this._state.pulseSkills;
			case "languages": return this._state.pulseLanguages = !this._state.pulseLanguages;
			case "tools": return this._state.pulseTools = !this._state.pulseTools;
			case "armor": return this._state.pulseArmor = !this._state.pulseArmor;
			case "weapons": return this._state.pulseWeapons = !this._state.pulseWeapons;
			case "features": return this._state.pulseFeatures = !this._state.pulseFeatures;
			case "savingThrows": return this._state.pulseSavingThrows = !this._state.pulseSavingThrows;
			case "immune": return this._state.pulseImmune = !this._state.pulseImmune;
			case "resist": return this._state.pulseResist = !this._state.pulseResist;
			case "vulnerable": return this._state.pulseVulnerable = !this._state.pulseVulnerable;
			case "conditionImmune": return this._state.pulseConditionImmune = !this._state.pulseConditionImmune;
			case "expertise": return this._state.pulseExpertise = !this._state.pulseExpertise;
			default: throw new Error(`Unhandled tracked state key ${k}`);
		}
	}

	_getDefaultState () {
		return [...Charactermancer_FeatureSourceTracker._VALID_HOOK_PROPS].mergeMap(it => ({[it]: false}));
	}
}
Charactermancer_FeatureSourceTracker._VALID_HOOK_PROPS = new Set([
	"pulseSkills",
	"pulseLanguages",
	"pulseTools",
	"pulseArmor",
	"pulseWeapons",
	"pulseFeatures",
	"pulseSavingThrows",
	"pulseImmune",
	"pulseResist",
	"pulseVulnerable",
	"pulseConditionImmune",
	"pulseExpertise",
]);

class Charactermancer_AbilityScoreSelect extends BaseComponent {
	// region External
	static async pFillActorAbilityData (actor, ability, actUpdate, opts) {
		if (!ability || !ability.length) return;

		const formData = await this.pGetUserInput(ability);
		if (!formData) return opts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		actUpdate.data = actUpdate.data || {};
		actUpdate.data.abilities = actUpdate.data.abilities || {};
		const abilityScores = Charactermancer_Util.getBaseAbilityScores(actor);
		Parser.ABIL_ABVS.filter(ab => formData.data[ab]).forEach(ab => actUpdate.data.abilities[ab] = {value: abilityScores[ab] + formData.data[ab]});

		return formData;
	}

	static async pGetUserInput (ability) {
		if (!ability || !ability.length) return {isFormComplete: true, data: {}};

		const comp = new Charactermancer_AbilityScoreSelect({ability});
		if (comp.isNoChoice()) {
			const isFill = await InputUiUtil.pGetUserBoolean({
				title: `Ability Scores?`,
				htmlDescription: `Do you wish to apply ability score modifications?`,
				textYes: "Yes",
				textNo: "No",
			});
			if (isFill == null) return null;
			if (!isFill) return {isFormComplete: true, data: {}};
			return comp.pGetFormData();
		}

		return UtilApplications.pGetImportCompModalFormData({comp});
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.ability
	 */
	constructor (opts) {
		opts = opts || {};
		super();

		this._ability = opts.ability;

		this._lastMeta = null;
	}

	get modalTitle () { return "Ability Score Increase"; }

	render ($wrp) {
		let $stgSelGroup = null;
		if (this._ability.length > 1) {
			const $selIxSet = ComponentUiUtil.$getSelEnum(
				this,
				"ixSet",
				{
					values: this._ability.map((_, i) => i),
					fnDisplay: ix => Renderer.getAbilityData([this._ability[ix]]).asText,
				},
			);

			$stgSelGroup = $$`<div class="w-100 mb-2 flex-vh-center">
				${$selIxSet}
			</div>`;
		}

		const $stgGroup = $$`<div class="flex-col"></div>`;

		const hkIxSet = () => {
			$stgGroup.empty();

			if (this._lastMeta) this._lastMeta.cleanup();

			const abilitySet = this._ability[this._state.ixSet];

			const ptsNoChoose = Parser.ABIL_ABVS.filter(ab => abilitySet[ab]).map(ab => `${Parser.attAbvToFull(ab)} ${UiUtil.intToBonus(abilitySet[ab])}`);
			if (ptsNoChoose.length) $stgGroup.append(`<div>${ptsNoChoose.join(", ")}</div>${abilitySet.choose ? `<hr class="hr-2 hr--dotted">` : ""}`);

			if (abilitySet.choose) {
				if (abilitySet.choose.from) this._render_renderPtChooseFrom($stgGroup, abilitySet);
				else if (abilitySet.choose.weighted) this._render_renderPtChooseWeighted($stgGroup, abilitySet);
			}
		};
		this._addHookBase("ixSet", hkIxSet);
		hkIxSet();

		$$($wrp)`
			${$stgSelGroup}
			${$stgGroup}
		`;
	}

	_render_renderPtChooseFrom ($stgGroup, abilitySet) {
		const count = abilitySet.choose.count || 1;
		const amount = abilitySet.choose.amount || 1;

		this._lastMeta = ComponentUiUtil.getMetaWrpMultipleChoice(
			this,
			"race_asiChoice",
			{
				values: abilitySet.choose.from,
				fnDisplay: v => `${Parser.attAbvToFull(v)} ${UiUtil.intToBonus(amount)}`,
				count,
			},
		);

		$stgGroup.append(`<div class="mb-1">Choose ${Parser.numberToText(count)} ability score${count === 1 ? "" : "s"} to increase by ${amount}:</div>`)
		this._lastMeta.$ele.appendTo($stgGroup);
	}

	_getWeightedProps (ixAb, ixWeight) {
		return {
			propWeightAbility: `weight_ability_${ixAb}_${ixWeight}`,
		}
	}

	_render_renderPtChooseWeighted ($stgGroup, abilitySet) {
		const fnsCleanup = [];

		const from = this.constructor._getSortedWeightedFrom(abilitySet);
		const weights = this.constructor._getSortedWeights(abilitySet);

		const $wrpRows = $(`<div class="flex-col"></div>`);

		from.forEach((ab, ixAb) => {
			const $wrpsCbs = weights
				.map((bon, ixBon) => {
					const {propWeightAbility} = this._getWeightedProps(ixAb, ixBon);

					const metaCb = ComponentUiUtil.$getCbBool(
						this,
						propWeightAbility,
						{asMeta: true},
					);
					fnsCleanup.push(metaCb.unhook);

					const hkResetOthers = () => {
						if (!this._state[propWeightAbility]) return;

						const nxtState = {};

						// Unset any other checkboxes in this column (i.e. same ixBon)
						from.forEach((_, ixAbSub) => {
							const {propWeightAbility: propWeightAbilitySub} = this._getWeightedProps(ixAbSub, ixBon);
							if (propWeightAbilitySub === propWeightAbility) return;
							nxtState[propWeightAbilitySub] = false;
						});

						// Unset any other checkboxes in this row (i.e. same ixAb)
						weights.forEach((_, ixBonSub) => {
							const {propWeightAbility: propWeightAbilitySub} = this._getWeightedProps(ixAb, ixBonSub);
							if (propWeightAbilitySub === propWeightAbility) return;
							nxtState[propWeightAbilitySub] = false;
						});

						this._proxyAssignSimple("state", nxtState);
					};
					this._addHookBase(propWeightAbility, hkResetOthers);
					fnsCleanup.push(() => this._removeHookBase(propWeightAbility, propWeightAbility));
					hkResetOthers();

					return $$`<label class="flex-vh-center w-40p py-1">${metaCb.$cb}</label>`;
				});

			const $row = $$`<div class="flex-v-center">
				<div class="w-100p text-right pr-2">${Parser.attAbvToFull(ab)}</div>
				${$wrpsCbs}
			</div>`.appendTo($wrpRows);
		});

		$$($stgGroup)`<div class="flex-col">
			<div class="mb-1">${Renderer.getAbilityData([{choose: abilitySet.choose}]).asText}:</div>

			<div class="flex-v-center py=1">
				<div class="w-100p"></div>
				${weights.map(it => `<div class="w-40p flex-vh-center">${UiUtil.intToBonus(it)}</div>`).join("")}
			</div>

			${$wrpRows}
		</div>`;

		this._lastMeta = {
			cleanup: () => {
				fnsCleanup.forEach(it => it());
			},
		};
	}

	isNoChoice () { return this._ability.length === 1 && !this._ability[0].choose; }

	static _getSortedWeights (abilitySet) { return MiscUtil.copy(abilitySet.choose.weighted.weights).sort((a, b) => SortUtil.ascSort(b, a)); }
	static _getSortedWeightedFrom (abilitySet) { return MiscUtil.copy(abilitySet.choose.weighted.from).sort(SortUtil.ascSortAtts); }

	pGetFormData () {
		const out = {};

		const abilitySet = this._ability[this._state.ixSet];

		// Add static values
		Parser.ABIL_ABVS.forEach(ab => { if (abilitySet[ab]) out[ab] = abilitySet[ab]; });

		if (abilitySet.choose) {
			if (abilitySet.choose.from) {
				const ixs = ComponentUiUtil.getMetaWrpMultipleChoice_getSelectedIxs(this, "race_asiChoice");
				ixs.map(it => abilitySet.choose.from[it]).forEach(ab => out[ab] = (out[ab] || 0) + (abilitySet.choose.amount || 1));
			} else if (abilitySet.choose.weighted) {
				const from = this.constructor._getSortedWeightedFrom(abilitySet);
				const weights = this.constructor._getSortedWeights(abilitySet);

				from.forEach((ab, ixAb) => {
					weights
						.map((bon, ixBon) => {
							const {propWeightAbility} = this._getWeightedProps(ixAb, ixBon);
							if (this._state[propWeightAbility]) out[ab] = (out[ab] || 0) + bon;
						});
				});
			}
		}

		return {
			isFormComplete: !!this._state[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsAcceptable("race_asiChoice")],
			data: out,
		};
	}

	_getDefaultState () {
		return {
			ixSet: 0,
		}
	}
}

class Charactermancer_ProficiencySelect extends BaseComponent {}

Charactermancer_ProficiencySelect.PropGroup = class {
	constructor ({prop, propTrackerPulse, propTracker}) {
		this.prop = prop;
		this.propTrackerPulse = propTrackerPulse;
		this.propTracker = propTracker;
	}
}

class Charactermancer_SkillSaveProficiencySelect extends Charactermancer_ProficiencySelect {
	// region External
	/**
	 * @param opts
	 * @param opts.existingFvtt
	 * @param opts.available
	 * @param [opts.titlePrefix]
	 * @param [opts.otherData]
	 */
	static async pGetUserInput (opts) {
		opts = opts || {};

		if (!opts.available) return {isFormComplete: true, data: {}};

		const comp = new this({...opts, existing: this.getExisting(opts.existingFvtt), otherData: opts.otherData});
		if (comp.isNoChoice()) return comp.pGetFormData();

		return UtilApplications.pGetImportCompModalFormData({comp});
	}

	/**
	 * @param existingFvtt
	 * @return {*}
	 */
	static getExisting (existingFvtt) { throw new Error(`Unimplemented!`); }

	static isNoChoice (available) {
		if (!available?.length) return true; // If there's no data, there's no choice
		return available.length === 1 && !available[0].choose;
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.propGroup Object of the form `{prop, propTracker, propTrackerPulse}`
	 * @param opts.existing
	 * @param opts.available
	 * @param opts.allValuesMaybeInUse
	 * @param [opts.otherData]
	 * @param [opts.titlePrefix]
	 * @param [opts.featureSourceTracker]
	 * @param [opts.modalTitle]
	 * @param [opts.title]
	 * @param [opts.titlePlural]
	 */
	constructor (opts) {
		opts = opts || {};
		super();

		this._propGroup = opts.propGroup;
		this._existing = opts.existing;
		this._available = opts.available;
		this._otherData = opts.otherData;
		this._titlePrefix = opts.titlePrefix;
		this._featureSourceTracker = opts.featureSourceTracker;
		this._modalTitle = opts.modalTitle;
		this._title = opts.title;
		this._titlePlural = opts.titlePlural;
		this._allValuesMaybeInUse = opts.allValuesMaybeInUse;

		this._hkUpdateExisting = null;
		this._$stgGroup = null;
		this._lastMeta = null;
	}

	get modalTitle () { return this._modalTitle; }

	/** @return {*} */
	_getStaticDisplay (prof) { throw new Error(`Unimplemented!`); }
	/** @return {*} */
	_getMultiChoiceDisplay ($ptsExisting, profOrObj) { throw new Error(`Unimplemented!`); }
	/** @return {*} */
	_getMultiChoiceTitle (cpyProfSet, count) { throw new Error(`Unimplemented!`); }

	_getNonStaticDisplay (key, value) {
		switch (key) {
			case "choose": return this._getChooseFromDisplay(key, value);
			default: throw new Error(`Unhandled non-static key "${key}" (value was ${JSON.stringify(value)})`);
		}
	}

	_getChooseFromDisplay (key, value) {
		return `Choose ${value.count || 1} from ${value.from.map(it => this._getStaticDisplay(it)).join(", ")}`;
	}

	render ($wrp) {
		const $stgSelGroup = this._render_$getStgSelGroup();
		this._$stgGroup = $$`<div class="flex-col"></div>`;

		this._addHookBase("ixSet", this._hk_ixSet.bind(this));
		this._hk_ixSet();

		$$($wrp)`
			${$stgSelGroup}
			${this._$stgGroup}
		`;
	}

	_render_$getStgSelGroup () {
		if (this._available.length <= 1) return null;

		const $selIxSet = ComponentUiUtil.$getSelEnum(
			this,
			"ixSet",
			{
				values: this._available.map((_, i) => i),
				fnDisplay: ix => {
					const v = this._available[ix];

					const out = [];

					out.push(
						Object.keys(v)
							.sort(SortUtil.ascSortLower)
							.filter(it => this._isStaticKey(it))
							.map(k => this._getStaticDisplay(k))
							.join(", "),
					);

					Object.keys(v)
						.filter(it => !this._isStaticKey(it))
						.forEach(k => out.push(this._getNonStaticDisplay(k, v[k])));

					return out.filter(Boolean).join("; ");
				},
			},
		);

		if (this._featureSourceTracker) this._addHookBase("ixSet", () => this._doSetTrackerState());

		return $$`<div class="w-100 mb-2 flex-vh-center">
			${$selIxSet}
		</div>`;
	}

	_doSetTrackerState () {
		this._featureSourceTracker.setState(this, {[this._propGroup.propTracker]: this._getFormData().data?.[this._propGroup.prop]})
	}

	static _getSortedProfSet (profSet) {
		if (!profSet) return profSet;

		profSet = MiscUtil.copy(profSet);

		if (profSet.choose?.from) {
			profSet.choose.from.sort((a, b) => {
				if (typeof a === "object" && typeof b === "object") return 0;
				if (typeof a === "object") return 1;
				if (typeof b === "object") return -1;
				return SortUtil.ascSortLower(a, b);
			});
		}

		return profSet;
	}

	_render_renderPtStatic ($stgGroup, profSet) {
		const $ptsExisting = {};

		const profList = this._getStaticKeys()
			.filter(key => profSet[key]);

		const $wrps = profList
			.map((it, i) => {
				const $ptExisting = $(`<div class="ve-small veapp__msg-warning inline-block"></div>`);
				($ptsExisting[it] = $ptsExisting[it] || []).push($ptExisting);
				const isNotLast = i < profList.length - 1;
				return $$`<div class="inline-block ${isNotLast ? "mr-1" : ""}">${this._getStaticDisplay(it)}${$ptExisting}${isNotLast ? `,` : ""}</div>`;
			});

		$$`<div class="block">
			${$wrps}
		</div>`.appendTo($stgGroup);

		return $ptsExisting;
	}

	_getStaticKeys () { return this._allValuesMaybeInUse; }

	_hk_ixSet () {
		this._$stgGroup.empty();

		if (this._featureSourceTracker && this._hkUpdateExisting) this._featureSourceTracker.removeHook(this, this._propGroup.propTrackerPulse, this._hkUpdateExisting);
		if (this._lastMeta) this._lastMeta.cleanup();

		const profSet = this._available[this._state.ixSet];

		if (this._featureSourceTracker) this._doSetTrackerState();

		this._hk_ixSet_renderPts(profSet);

		if (this._featureSourceTracker) this._featureSourceTracker.addHook(this, this._propGroup.propTrackerPulse, this._hkUpdateExisting);
		this._hkUpdateExisting();
	}

	_hk_ixSet_renderPts (profSet) {
		const $ptsExistingStatic = Object.keys(profSet).some(it => this._isStaticKey(it)) ? this._render_renderPtStatic(this._$stgGroup, profSet) : null;

		if ($ptsExistingStatic && profSet.choose) this._$stgGroup.append(`<hr class="hr-2 hr--dotted">`);
		const $ptsExistingChooseFrom = profSet.choose ? this._render_renderPtChooseFrom(this._$stgGroup, profSet) : null;

		this._hkUpdateExisting = () => this._hk_updatePtsExisting($ptsExistingStatic, $ptsExistingChooseFrom);
	}

	_isStaticKey (key) { return this._allValuesMaybeInUse.includes(key); }

	_hk_updatePtsExisting ($ptsExistingStatic, $ptsExistingChoose) {
		const otherStates = this._featureSourceTracker ? this._featureSourceTracker.getStatesForKey(this._propGroup.propTracker, {ignore: this}) : null;

		const $ptsExistings = [$ptsExistingStatic, $ptsExistingChoose].filter(Boolean);

		this._allValuesMaybeInUse
			.forEach(prof => {
				$ptsExistings.forEach($ptsExisting => {
					if (!$ptsExisting[prof]) return;

					// Value from sheet
					let maxExisting = this._existing?.[prof] || 0;

					// Value from other networked components
					if (otherStates) otherStates.forEach(otherState => maxExisting = Math.max(maxExisting, otherState[prof] || 0));

					if (maxExisting) {
						const helpText = maxExisting === 1
							? `Proficient from Another Source`
							: maxExisting === 2 ? `Proficient with Expertise from Another Source` : `Half-Proficient from Another Source`;

						$ptsExisting[prof]
							.forEach($ptExisting => {
								$ptExisting
									.title(helpText)
									.addClass("ml-1")
									.html(`(<i class="fas ${UtilActors.PROF_TO_ICON_CLASS[maxExisting]}"></i>)`);
							});
					} else {
						$ptsExisting[prof]
							.forEach($ptExisting => {
								$ptExisting
									.title("")
									.removeClass("ml-1")
									.html("");
							});
					}
				})
			});
	}

	_render_renderPtChooseFrom ($stgGroup, profSet) {
		const count = profSet.choose.count || 1;

		const cpyProfSet = this.constructor._getSortedProfSet(profSet);

		const $ptsExisting = {};
		const multiChoiceMeta = ComponentUiUtil.getMetaWrpMultipleChoice(
			this,
			"proficiencyChoice",
			{
				count,
				values: cpyProfSet.choose.from,
				fnDisplay: profOrObj => this._getMultiChoiceDisplay($ptsExisting, profOrObj),
			},
		);

		// region Networking with other proficiency select components
		let hkSetTrackerInfo = null;
		if (this._featureSourceTracker) {
			hkSetTrackerInfo = () => this._doSetTrackerState();
			this._addHookBase(multiChoiceMeta.propPulse, hkSetTrackerInfo);
		}
		// endregion

		$stgGroup.append(`<div class="mb-1">${this._getMultiChoiceTitle(cpyProfSet, count)}:</div>`);
		multiChoiceMeta.$ele.appendTo($stgGroup);

		this._lastMeta = {
			cleanup: () => {
				multiChoiceMeta.cleanup();
				if (hkSetTrackerInfo) this._removeHookBase(multiChoiceMeta.propPulse, hkSetTrackerInfo);
			},
		};

		return $ptsExisting;
	}

	isNoChoice () { return this.constructor.isNoChoice(this._available); }

	_getFormData () {
		const out = {};

		const profSet = this._available[this._state.ixSet];

		const cpyProfSet = this.constructor._getSortedProfSet(profSet);

		// Add static values
		this._allValuesMaybeInUse.filter(name => cpyProfSet[name]).map(name => out[name] = 1);

		if (cpyProfSet.choose) {
			const ixs = ComponentUiUtil.getMetaWrpMultipleChoice_getSelectedIxs(this, "proficiencyChoice");
			ixs.map(it => cpyProfSet.choose.from[it]).forEach(name => out[name] = 1);
		}

		return {
			isFormComplete: !!this._state[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsAcceptable("proficiencyChoice")],
			data: {
				[this._propGroup.prop]: out,
			},
		};
	}

	pGetFormData () { return this._getFormData(); }

	_getDefaultState () {
		return {
			ixSet: 0,
		};
	}
}

class Charactermancer_SkillProficiencySelect extends Charactermancer_SkillSaveProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		// Convert Foundry existing proficiencies to 5etools names
		const existing = {};

		Object.entries(existingFvtt?.skillProficiencies || {})
			.forEach(([abv, data]) => {
				if (!data.value) return;
				existing[UtilActors.SKILL_ABV_TO_FULL[abv]] = data.value;
			});

		return existing;
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			allValuesMaybeInUse: Object.keys(Parser.SKILL_TO_ATB_ABV),
			propGroup: new Charactermancer_ProficiencySelect.PropGroup({
				prop: "skillProficiencies",
				propTrackerPulse: "pulseSkills",
				propTracker: "skills",
			}),
			modalTitle: "Skill Proficiencies",
			title: "Skill Proficiency",
			titlePlural: "Skill Proficiencies",
			tag: "@skill",
		});
	}

	_getStaticDisplay (prof) { return Renderer.get().render(`{@skill ${prof.toTitleCase()}}`); }

	_getMultiChoiceDisplay ($ptsExisting, profOrObj) {
		if (profOrObj.tool === true) return "Tool Proficiency";
		if (profOrObj.tool) return profOrObj.tool.map(t => t.toTitleCase());

		const atb = Parser.skillToAbilityAbv(profOrObj);
		const ptAbility = `<div class="ml-1 ve-small ve-muted" title="${Parser.attAbvToFull(atb)}">(${atb.toTitleCase()})</div>`;

		const $ptExisting = $(`<div class="ve-small veapp__msg-warning"></div>`);
		($ptsExisting[profOrObj] = $ptsExisting[profOrObj] || []).push($ptExisting);

		return $$`<div class="flex-v-center w-100">
			<div class="flex-v-center">${Renderer.get().render(`{@skill ${profOrObj.toTitleCase()}}`)}${ptAbility}</div>${$ptExisting}
		</div>`;
	}

	_getMultiChoiceTitle (cpyProfSet, count) {
		const isAnyTool = cpyProfSet.choose.from.some(it => typeof it === "object" && it.tool);
		return `${this._titlePrefix ? `${this._titlePrefix}: ` : ""}Choose ${Parser.numberToText(count)} Skill${count === 1 ? "" : "s"}${isAnyTool ? `/Tool${count === 1 ? "" : "s"}` : ""}`;
	}
}

class Charactermancer_SavingThrowProficiencySelect extends Charactermancer_SkillSaveProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		// Convert Foundry existing proficiencies to 5etools names
		const existing = {};

		Object.entries(existingFvtt?.savingThrowProficiencies || {})
			.forEach(([ab, data]) => {
				if (!data.proficient) return;
				existing[ab] = data.proficient;
			});

		return existing;
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			allValuesMaybeInUse: [...Parser.ABIL_ABVS],
			propGroup: new Charactermancer_ProficiencySelect.PropGroup({
				prop: "savingThrowProficiencies",
				propTrackerPulse: "pulseSavingThrows",
				propTracker: "savingThrows",
			}),
			modalTitle: "Saving Throw Proficiencies",
			title: "Saving Throw Proficiency",
			titlePlural: "Saving Throw Proficiencies",
		});
	}

	_getStaticDisplay (prof) { return Parser.attAbvToFull(prof).toTitleCase(); }

	_getMultiChoiceDisplay ($ptsExisting, profOrObj) {
		const $ptExisting = $(`<div class="ve-small veapp__msg-warning"></div>`);
		($ptsExisting[profOrObj] = $ptsExisting[profOrObj] || []).push($ptExisting);

		return $$`<div class="flex-v-center w-100">
			<div class="flex-v-center">${Renderer.get().render(Parser.attAbvToFull(profOrObj).toTitleCase())}</div>${$ptExisting}
		</div>`;
	}

	_getMultiChoiceTitle (cpyProfSet, count) {
		return `${this._titlePrefix ? `${this._titlePrefix}: ` : ""}Choose ${Parser.numberToText(count)} ${count === 1 ? this._title : this._titlePlural}`;
	}
}

class Charactermancer_OtherProficiencySelect extends Charactermancer_ProficiencySelect {
	// region External
	/**
	 * @param opts
	 * @param opts.existingFvtt
	 * @param opts.available
	 * @param [opts.titlePrefix]
	 */
	static async pGetUserInput (opts) {
		opts = opts || {};

		if (!opts.available) return {isFormComplete: true, data: {}};

		const comp = new this({...opts, existing: this.getExisting(opts.existingFvtt)});
		if (comp.isNoChoice()) return comp.pGetFormData();

		return UtilApplications.pGetImportCompModalFormData({comp});
	}

	/**
	 * @param existingFvtt
	 * @return {*}
	 */
	static getExisting (existingFvtt) { throw new Error(`Unimplemented!`); }

	static isNoChoice (available) {
		if (!available?.length) return true; // If there's no data, there's no choice

		return available.length === 1
			&& !available[0].choose
			// These are expanded to "choose" objects
			&& !Object.keys(this._ANY_OPTIONS).some(anyKey => available[0][anyKey]);
	}
	// endregion

	static _getExistingFvttProficiencySetsMeta (existingFvtt) {
		return {
			existingProficienciesFvttSet: new Set(existingFvtt?.value || []),
			existingProficienciesFvttSetCustom: new Set((existingFvtt?.custom || "").split(";").map(it => it.trim().toLowerCase()).filter(Boolean)),
		};
	}

	/**
	 * @param opts
	 * @param opts.propGroups Object of the form `{prop, propTracker, propTrackerPulse}`
	 * @param opts.existing
	 * @param opts.available
	 * @param opts.title
	 * @param [opts.modalTitle]
	 * @param [opts.titlePlural]
	 * @param [opts.titlePrefix]
	 * @param [opts.anyOptions]
	 * @param [opts.otherTitle] The title to give to any `"other"` data, e.g. a misc language.
	 * @param [opts.featureSourceTracker]
	 * @param [opts.fnRenderKey]
	 * @param [opts.pFnGetParentGroup]
	 * @param [opts.pFnGetChoice]
	 * @param [opts.tag] E.g. `"@item"`
	 * @param [opts.valueGroupSplitControlsLookup] (Pass-through)
	 */
	constructor (opts) {
		opts = opts || {};
		super();

		this._propGroups = opts.propGroups;
		this._existing = opts.existing;
		this._available = this._getNormalizedAvailableProficiencies(opts.propGroups, opts.available, opts.anyOptions);
		this._modalTitle = opts.modalTitle;
		this._title = opts.title;
		this._titlePlural = opts.titlePlural;
		this._titlePrefix = opts.titlePrefix;
		this._anyOptions = opts.anyOptions;
		this._otherTitle = opts.otherTitle;
		this._featureSourceTracker = opts.featureSourceTracker;
		this._fnRenderKey = opts.fnRenderKey;
		this._pFnGetParentGroup = opts.pFnGetParentGroup;
		this._pFnGetChoice = opts.pFnGetChoice;
		this._tag = opts.tag;
		this._valueGroupSplitControlsLookup = opts.valueGroupSplitControlsLookup;

		this._lastMetas = [];
		this._hkExisting = null;
	}

	/**
	 * Convert e.g. `{common: true, choose: {from: ["giant", "orc"]}}` to
	 * `{static: [{prof: "common", prop: "languageProficiencies"<, count: 2>}], choose: {from: [{prof: "giant", prop: "languageProficiencies"}, ...]}}`
	 * Note that "any" options should be skipped, as these should be handled by the caller's "any options"
	 */
	_getNormalizedAvailableProficiencies (propGroups, availProfs, anyOpts) {
		// Assume any without a prop are from selects which only have 1 prop
		const defaultProp = this._propGroups[0].prop

		return availProfs
			.map(availProfSet => {
				const out = {};
				Object.entries(availProfSet)
					.forEach(([k, v]) => {
						if (!v) return;

						if (k === "choose") {
							out.choose = MiscUtil.copy(v);
							if (out.choose.from) {
								// It's a filter expression (e.g VGM Hobgoblin's weapon prof select)
								if (out.choose.from.prop || typeof out.choose.from === "string") {
									if (typeof out.choose.from === "string") out.choose.from = {prop: defaultProp, prof: out.choose.from};
								} else {
									out.choose.from = out.choose.from.map(it => {
										if (it.prop) return it;
										return {prop: defaultProp, prof: it};
									});
								}
							}
						} else if (anyOpts?.[k]) {
							// Keep "any"s as-is, since these have special handling elsewhere
							out.any = out.any || [];
							out.any.push({key: k, value: v});
						} else if (k === "static") {
							out.static = [
								...out.static,
								...v.map(it => {
									if (it.prop) return it;
									return {prop: defaultProp, prof: it};
								}),
							];
						} else {
							out.static = out.static || [];
							const it = {prop: defaultProp, prof: k};
							if (typeof v === "number") it.count = v;
							out.static.push(it);
						}
					});

				return out;
			});
	}

	get modalTitle () { return this._modalTitle; }

	render ($wrp) {
		const $stgSelGroup = this._render_$getStgSelGroup();

		const $stgGroup = $$`<div class="flex-col"></div>`;

		const hkIxSet = () => {
			$stgGroup.empty();

			if (this._featureSourceTracker && this._hkExisting) {
				this._propGroups.forEach(({propTrackerPulse}) => this._featureSourceTracker.removeHook(this, propTrackerPulse, this._hkExisting));
			}
			this._lastMetas.forEach(it => it.cleanup());
			this._lastMetas = [];

			const selProfs = this._available[this._state.ixSet];

			if (this._featureSourceTracker) this._doSetTrackerState();

			// region Static
			const $ptsExistingStatic = selProfs.static?.length ? this._render_renderPtStatic($stgGroup, selProfs.static) : null;
			// endregion

			if ($ptsExistingStatic && (selProfs.choose || selProfs.any)) $stgGroup.append(`<hr class="hr-2 hr--dotted">`);

			// region Choose from predefined
			const $ptsExistingChooseFrom = selProfs.choose
				? this.constructor._isChooseFromFilter(selProfs.choose)
					? this._render_renderPtChooseFromFilter($stgGroup, selProfs.choose)
					: this._render_renderPtChooseFrom($stgGroup, selProfs.choose, "choose")
				: null;
			// endregion

			// region Choose from "any"
			const $ptsExistingsChooseFromAny = [];
			if (selProfs.any) {
				selProfs.any.forEach(({key: anyKey, value: anyCount}) => {
					if (!this._anyOptions[anyKey]) return;

					// This can either be an array of choices,
					//   or an object containing some metadata and the array of choices
					const anyData = this._anyOptions[anyKey];

					const choiceObject = this.constructor._convertAnyToChoose(anyCount, anyData, selProfs.static);
					if (choiceObject) {
						const $ptsExistingChooseFromAny = this._render_renderPtChooseFrom($stgGroup, choiceObject, anyKey);
						$ptsExistingsChooseFromAny.push($ptsExistingChooseFromAny);
					}
				});
			}
			// endregion

			this._hkExisting = () => this._hk_pUpdatePtsExisting($ptsExistingStatic, $ptsExistingChooseFrom, $ptsExistingsChooseFromAny);
			if (this._featureSourceTracker) {
				this._propGroups.forEach(({propTrackerPulse}) => this._featureSourceTracker.addHook(this, propTrackerPulse, this._hkExisting));
			}
			this._hkExisting();
		};
		this._addHookBase("ixSet", hkIxSet);
		hkIxSet();

		$$($wrp)`
			${$stgSelGroup}
			${$stgGroup}
		`;
	}

	_doSetTrackerState () {
		this._propGroups.forEach(({prop, propTracker}) => this._featureSourceTracker.setState(this, {[propTracker]: this._getFormData().data?.[prop]}))
	}

	/** Get a select which chooses between multiple outer groups. */
	_render_$getStgSelGroup () {
		if (this._available.length <= 1) return null;

		const $selIxSet = ComponentUiUtil.$getSelEnum(
			this,
			"ixSet",
			{
				placeholder: `Select ${this._title} Set`,
				values: this._available.map((_, i) => i),
				fnDisplay: ix => {
					const selProfs = this._available[ix];

					const out = [];

					if (selProfs.static) {
						const pt = MiscUtil.copy(selProfs.static)
							.sort((a, b) => SortUtil.ascSortLower(a.prof, b.prof))
							.map(({prof, count}) => {
								const txt = prof === "other" ? this._otherTitle : this._fnRenderKey ? this._fnRenderKey(prof) : prof.toTitleCase();
								if (count) return `${txt} ${count}`;
								return txt;
							})
							.join(", ");
						out.push(pt);
					}

					if (selProfs.choose) {
						if (this.constructor._isChooseFromFilter(selProfs.choose)) out.push(`Choose ${Parser.numberToText(selProfs.choose.count || 1)} from filtered selection`);
						else out.push(`Choose ${Parser.numberToText(selProfs.choose.count || 1)} from ${selProfs.choose.from.map(({prof}) => prof.toTitleCase()).join(", ")}`);
					}

					return out.filter(Boolean).join("; ");
				},
			},
		);

		if (this._featureSourceTracker) {
			this._propGroups.forEach(({prop, propTracker}) => {
				const hk = () => this._featureSourceTracker.setState(this, {[propTracker]: this._getFormData().data?.[prop]});
				this._addHookBase("ixSet", hk);
			});
		}

		return $$`<div class="w-100 mb-2 flex-vh-center">
			${$selIxSet}
		</div>`;
	}

	static _isChooseFromFilter (v) {
		return typeof v.from.prof === "string";
	}

	_getAllValuesMaybeInUseLookup () {
		const out = {};

		Object.entries(this._available[this._state.ixSet] || {})
			.forEach(([k, v]) => {
				if (k === "choose" && this.constructor._isChooseFromFilter(v)) {
					const prefix = `${this._getJoinedProp()}_chooseFilter_`;
					// Collect the in-use UIDs from the state, since they could be anything we can filter for
					Object.entries(this._state)
						.filter(([k, v]) => k.startsWith(prefix) && v)
						.forEach(([, {prop, prof}]) => {
							out[prop] = out[prop] || new Set();
							out[prop].add(prof);
						});
				} else if (k === "choose") {
					const values = v.isGrouped
						? v.from.map(it => it.values).flat()
						: v.from;

					values.forEach(({prop, prof}) => {
						out[prop] = out[prop] || new Set();
						out[prop].add(prof);
					});
				} else if (k === "any") {
					v.forEach(({key: anyKey}) => {
						const anyOptionSet = this._anyOptions[anyKey];
						if (!anyOptionSet) return;

						const values = anyOptionSet.isGrouped
							? anyOptionSet.valueGroups.map(it => it.values).flat()
							: anyOptionSet;

						values.forEach(({prop, prof}) => {
							out[prop] = out[prop] || new Set();
							out[prop].add(prof);
						});
					});
				} else if (k === "static") {
					v.forEach(({prop, prof}) => {
						out[prop] = out[prop] || new Set();
						out[prop].add(prof);
					})
				} else throw new Error(`Unhandled normalized key "${k}"`);
			});

		return out;
	}

	_getJoinedProp () { return this._propGroups.map(({prop}) => prop).join("-"); }

	async _hk_pUpdatePtsExisting ($ptsExistingStatic, $ptsExistingChooseFrom, $ptsExistingsChooseFromAny) {
		try {
			await this._pLock("updateExisting");
			await this._hk_pUpdatePtsExisting_({$ptsExistingStatic, $ptsExistingChooseFrom, $ptsExistingsChooseFromAny});
		} finally {
			this._unlock("updateExisting");
		}
	}

	async _hk_pUpdatePtsExisting_ ({$ptsExistingStatic, $ptsExistingChooseFrom, $ptsExistingsChooseFromAny}) {
		const $ptsExistings = [$ptsExistingStatic, $ptsExistingChooseFrom, ...($ptsExistingsChooseFromAny || [])].filter(Boolean);

		const allValuesLookup = this._getAllValuesMaybeInUseLookup();
		const allValueLookupEntries = Object.entries(allValuesLookup);

		for (const [prop, allProfs] of allValueLookupEntries) {
			const propGroup = this._propGroups.find(it => it.prop === prop);
			if (!propGroup) return; // Should never occur

			const otherStates = this._featureSourceTracker ? this._featureSourceTracker.getStatesForKey(propGroup.propTracker, {ignore: this}) : null;

			for (const v of allProfs) {
				for (const $ptsExisting of $ptsExistings) {
					if (!$ptsExisting[prop]?.[v]) continue;

					const parentGroup = this._pFnGetParentGroup ? await this._pFnGetParentGroup(v) : null;

					// Value from sheet
					let isExisting = !!this._existing?.[v]
						|| (parentGroup && !!this._existing?.[parentGroup]);

					// Value from other networked components
					isExisting = isExisting
						|| (otherStates || []).some(otherState => !!otherState[v] || (parentGroup && !!otherState[parentGroup]));

					$ptsExisting[prop][v]
						.title(isExisting ? "Proficient from Another Source" : "")
						.toggleClass("ml-1", isExisting)
						.html(isExisting ? `(<i class="fas ${UtilActors.PROF_TO_ICON_CLASS[1]}"></i>)` : "");
				}
			}
		}
	}

	_render_renderPtStatic ($stgGroup, profsStatic) {
		const $ptsExisting = {};

		const byProp = {};
		profsStatic.forEach(({prop, prof, count}) => MiscUtil.set(byProp, prop, prof, count || true));

		const $wrps = Object.entries(byProp)
			.map(([prop, profsStaticSet]) => {
				const ptPropType = this._propGroups.length > 1 ? ` (${this._propGroups.find(it => it.prop === prop).name})` : "";
				const profsStaticSetKeys = Object.keys(profsStaticSet);
				return profsStaticSetKeys
					.map((it, i) => {
						const $ptExisting = $(`<div class="ve-small veapp__msg-warning inline-block"></div>`);
						MiscUtil.set($ptsExisting, prop, it, $ptExisting);
						const isNotLast = i < profsStaticSetKeys.length - 1;
						return $$`<div class="inline-block ${isNotLast ? "mr-1" : ""}">${this._fnRenderKey ? this._fnRenderKey(it) : it.toTitleCase()}${ptPropType}${$ptExisting}${isNotLast ? `,` : ""}</div>`;
					});
			})
			.flat();

		$$`<div class="block">
			${$wrps}
		</div>`.appendTo($stgGroup);

		return $ptsExisting;
	}

	_render_renderPtChooseFrom ($stgGroup, choiceObj, propSuffix) {
		const propState = `${this._getJoinedProp()}_${propSuffix}`;
		const count = choiceObj.count || 1;

		const $ptsExisting = {};
		const compOpts = {
			count,
			fnDisplay: ({prop, prof}) => {
				const $ptExisting = $(`<div class="ve-small veapp__msg-warning"></div>`);
				MiscUtil.set($ptsExisting, prop, prof, $ptExisting);

				return $$`<div class="flex-v-center w-100">
					<div class="flex-v-center">${prof.toTitleCase()}</div>
					${$ptExisting}
				</div>`;
			},
		};

		if (choiceObj.isGrouped) {
			compOpts.valueGroups = choiceObj.from;
			compOpts.valueGroupSplitControlsLookup = this._valueGroupSplitControlsLookup;
		} else compOpts.values = choiceObj.from;

		const meta = ComponentUiUtil.getMetaWrpMultipleChoice(
			this,
			propState,
			compOpts,
		);

		// region Networking with other proficiency select components
		let hkSetTrackerInfo = null;
		if (this._featureSourceTracker) {
			hkSetTrackerInfo = () => this._doSetTrackerState();
			this._addHookBase(meta.propPulse, hkSetTrackerInfo);
		}
		// endregion

		this._lastMetas.push({
			cleanup: () => {
				meta.cleanup();
				if (hkSetTrackerInfo) this._removeHookBase(meta.propPulse, hkSetTrackerInfo);
			},
		});

		$stgGroup.append(`<div class="mb-1">${this._titlePrefix ? `${this._titlePrefix}: ` : ""}Choose ${Parser.numberToText(count)} ${count === 1 ? this._title : this._titlePlural}:</div>`);
		meta.$ele.appendTo($stgGroup);

		return $ptsExisting;
	}

	static _convertAnyToChoose (anyCount, anyData, selStaticProfs) {
		const selStaticProfLookup = {};
		(selStaticProfs || []).forEach(({prop, prof}) => {
			selStaticProfLookup[prop] = selStaticProfLookup[prop] || new Set();
			selStaticProfLookup[prop].add(prof.toLowerCase());
		});

		if (anyData.isGrouped) {
			const validFrom = MiscUtil.copy(anyData.valueGroups);
			validFrom.forEach(it => {
				it.values.forEach(meta => meta.prof = meta.prof.toLowerCase());
				it.values = it.values.filter(({prop, prof}) => !selStaticProfLookup[prop] || !selStaticProfLookup[prop].has(prof));
			});

			return {
				isGrouped: true,
				from: validFrom,
				count: anyCount,
			};
		} else {
			return {
				from: MiscUtil.copy(anyData)
					.map(meta => {
						meta.prof = meta.prof.toLowerCase();
						return meta;
					})
					.filter(({prop, prof}) => !selStaticProfLookup[prop] || !selStaticProfLookup[prop].has(prof)),
				count: anyCount,
			}
		}
	}

	_render_renderPtChooseFromFilter ($stgGroup, choiceObj) {
		// TODO should return a map of `{prop: {prof: {$ele}}}`
		const $ptsExisting = {};

		const $row = $(`<div class="flex-v-center"></div>`);

		[...new Array(choiceObj.count || 1)].forEach((_, i) => {
			const propState = `${this._getJoinedProp()}_chooseFilter_${i}`;

			const $ptExisting = $(`<div class="ve-small veapp__msg-warning"></div>`);

			const $disp = $(`<div class="flex-v-center"></div>`);
			const hkChosen = (propHk, valueHk, prevValueHk) => {
				const isFirstRun = !propHk;
				if (!isFirstRun) {
					const {prop: propPrev, prof: proPrev} = prevValueHk || {};
					const uidPrev = (proPrev || "").toLowerCase();
					MiscUtil.delete($ptsExisting, propPrev, uidPrev);

					const {prop, prof} = valueHk || {};
					const uid = (prof || "").toLowerCase();
					MiscUtil.set($ptsExisting, prop, uid);
				}

				$disp.html(
					this._state[propState] != null
						? `<div>${Renderer.get().render(`{${this._tag} ${this._state[propState].prof.toLowerCase()}}`)}</div>`
						: `<div class="italic ve-muted">(select a ${this._title.toLowerCase()})</div>`,
				);

				if (!isFirstRun && this._featureSourceTracker) this._doSetTrackerState();
			};
			this._addHookBase(propState, hkChosen);
			this._lastMetas.push({cleanup: () => this._removeHookBase(propState, hkChosen)});
			hkChosen();

			const $btnFilter = $(`<button class="btn btn-default btn-xxs mr-1" title="Choose a ${this._title}"><span class="fas fa-search"></span></button>`)
				.click(async () => {
					// region TODO(Future) add support for `propGroups` (see `Charactermancer_WeaponProficiencySelect`)
					const selecteds = await this._pFnGetChoice({filterExpression: choiceObj.from.prof}); // Pass in filter expression
					if (selecteds == null || !selecteds.length) return;

					const selected = selecteds[0];

					const propDefault = this._propGroups[0].prop;
					this._state[propState] = {prop: propDefault, prof: `${selected.name}|${selected.values.sourceJson}`.toLowerCase()};
					// endregion
				});

			$$`<div class="flex-v-center mr-1">${$btnFilter}${$disp}${$ptExisting}</div>`.appendTo($row);
		});

		$$`<div class="py-1 flex-v-center">
			${$row}
		</div>`.appendTo($stgGroup);

		return $ptsExisting;
	}

	isNoChoice () { return this.constructor.isNoChoice(this._available); }

	_getFormData () {
		let isFormComplete = true;
		const out = {};

		const selProfs = this._available[this._state.ixSet];

		// region Static
		(selProfs.static || []).forEach(({prop, prof}) => MiscUtil.set(out, prop, prof, true));
		// endregion

		// region Choose
		if (selProfs.choose && this.constructor._isChooseFromFilter(selProfs.choose)) {
			[...new Array(selProfs.choose.count || 1)].forEach((_, i) => {
				const propState = `${this._getJoinedProp()}_chooseFilter_${i}`;
				if (!this._state[propState]) isFormComplete = false;
				else {
					const {prop, prof} = this._state[propState];
					MiscUtil.set(out, prop, prof, true);
				}
			});
		} else if (selProfs.choose) {
			const propChoose = `${this._getJoinedProp()}_choose`;

			const ixs = ComponentUiUtil.getMetaWrpMultipleChoice_getSelectedIxs(this, propChoose);
			ixs.map(it => selProfs.choose.from[it]).forEach(({prop, prof}) => MiscUtil.set(out, prop, prof, true));

			if (!this._state[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsAcceptable(propChoose)]) isFormComplete = false;
		}
		// endregion

		// region Any
		if (selProfs.any) {
			selProfs.any.forEach(({key: anyKey, value: anyCount}) => {
				if (!this._anyOptions[anyKey]) return;

				const propAny = `${this._getJoinedProp()}_${anyKey}`;

				// This can either be an array of choices,
				//   or an object containing some metadata and the array of choices
				const anyData = this._anyOptions[anyKey];

				const choiceObject = this.constructor._convertAnyToChoose(anyCount, anyData, selProfs.static);

				if (choiceObject) {
					const optsGetSelected = {};
					if (choiceObject.isGrouped) optsGetSelected.valueGroups = choiceObject.from;
					else optsGetSelected.values = choiceObject.from;
					ComponentUiUtil.getMetaWrpMultipleChoice_getSelectedValues(this, propAny, optsGetSelected)
						.forEach(({prop, prof}) => MiscUtil.set(out, prop, prof, true));
				}

				if (!this._state[ComponentUiUtil.getMetaWrpMultipleChoice_getPropIsAcceptable(propAny)]) isFormComplete = false;
			});
		}
		// endregion

		return {
			isFormComplete,
			data: out,
		};
	}

	pGetFormData () { return this._getFormData(); }

	_getDefaultState () {
		return {
			ixSet: 0,
		}
	}

	static _getExistingProficiencies ({existingProficienciesSetFvtt, vetToFvttProfs, allProfsVet}) {
		const {existingProficienciesFvttSet, existingProficienciesFvttSetCustom} = this._getExistingFvttProficiencySetsMeta(existingProficienciesSetFvtt);

		const existing = {};

		Object.entries(vetToFvttProfs)
			.filter(([_, fvtt]) => existingProficienciesFvttSet.has(fvtt))
			.forEach(([vet, fvtt]) => {
				existing[vet] = true;
				existingProficienciesFvttSet.delete(fvtt);
			});

		allProfsVet.forEach(vet => {
			if (existingProficienciesFvttSet.has(vet)) {
				existing[vet] = true;
				existingProficienciesFvttSet.delete(vet);
			} else if (existingProficienciesFvttSetCustom.has(vet)) {
				existing[vet] = true;
				existingProficienciesFvttSetCustom.delete(vet);
			}
		});

		if (existingProficienciesFvttSet.size || existingProficienciesFvttSetCustom.size) {
			existing.other = existingProficienciesFvttSet.size + existingProficienciesFvttSetCustom.size;
		}

		return existing;
	}

	static get _ANY_OPTIONS () { return {}; }
}

class Charactermancer_LanguageProficiencySelect extends Charactermancer_OtherProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		return this._getExistingProficiencies({
			existingProficienciesSetFvtt: existingFvtt?.languageProficiencies,
			vetToFvttProfs: UtilActors.VALID_LANGUAGES,
			allProfsVet: Parser.LANGUAGES_ALL,
		});
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			propGroups: [
				Charactermancer_LanguageProficiencySelect.PROP_GROUP_,
			],
			modalTitle: "Language Proficiencies",
			title: "Language",
			titlePlural: "Languages",
			otherTitle: "(Other language)",
			anyOptions: Charactermancer_LanguageProficiencySelect._ANY_OPTIONS,
		});
	}

	static get _ANY_OPTIONS () {
		return {
			any: Parser.LANGUAGES_ALL.map(it => ({prof: it.toLowerCase(), prop: "languageProficiencies"})),
			anyStandard: {
				isGrouped: true,
				valueGroups: Charactermancer_LanguageProficiencySelect.ANY_OPT_ANY_STANDARD__VALUE_GROUPS,
			},
		};
	}
}
Charactermancer_LanguageProficiencySelect.PROP_GROUP_ = new Charactermancer_ProficiencySelect.PropGroup({
	prop: "languageProficiencies",
	propTrackerPulse: "pulseLanguages",
	propTracker: "languages",
});
Charactermancer_LanguageProficiencySelect.ANY_OPT_ANY_STANDARD__VALUE_GROUPS = [
	{
		name: "Standard Languages",
		values: Parser.LANGUAGES_STANDARD.map(it => ({prof: it.toLowerCase(), prop: "languageProficiencies"})),
	},
	{
		name: "Exotic Languages",
		text: "With your DM's permission, you can choose an exotic language.",
		values: Parser.LANGUAGES_EXOTIC.map(it => ({prof: it.toLowerCase(), prop: "languageProficiencies"})),
	},
	{
		name: "Secret Languages",
		text: "With your DM's permission, you can choose a secret language.",
		values: Parser.LANGUAGES_SECRET.map(it => ({prof: it.toLowerCase(), prop: "languageProficiencies"})),
	},
];

class Charactermancer_ToolProficiencySelect extends Charactermancer_OtherProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		return this._getExistingProficiencies({
			existingProficienciesSetFvtt: existingFvtt?.toolProficiencies,
			vetToFvttProfs: UtilActors.VALID_TOOL_PROFICIENCIES,
			allProfsVet: UtilActors.TOOL_PROFICIENCIES,
		});
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			propGroups: [
				Charactermancer_ToolProficiencySelect.PROP_GROUP_,
			],
			modalTitle: "Tool Proficiencies",
			title: "Tool Proficiency",
			titlePlural: "Tool Proficiencies",
			otherTitle: "(Other tool proficiency)",
		})
	}
}
Charactermancer_ToolProficiencySelect.PROP_GROUP_ = new Charactermancer_ProficiencySelect.PropGroup({
	prop: "toolProficiencies",
	propTrackerPulse: "pulseTools",
	propTracker: "tools",
});

class Charactermancer_LanguageToolProficiencySelect extends Charactermancer_OtherProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		const existingLanguageProfs = this._getExistingProficiencies({
			existingProficienciesSetFvtt: existingFvtt?.languageProficiencies,
			vetToFvttProfs: UtilActors.VALID_LANGUAGES,
			allProfsVet: Parser.LANGUAGES_ALL,
		});

		const existingToolProfs = this._getExistingProficiencies({
			existingProficienciesSetFvtt: existingFvtt?.toolProficiencies,
			vetToFvttProfs: UtilActors.VALID_TOOL_PROFICIENCIES,
			allProfsVet: UtilActors.TOOL_PROFICIENCIES,
		});

		// Merge everything into one big profs object, as we assume there are no name collisions
		const cntOther = (existingLanguageProfs.other || 0) + (existingToolProfs.other || 0);
		const out = MiscUtil.copy(existingLanguageProfs);
		Object.entries(existingToolProfs).filter(([, v]) => v).forEach(([k, v]) => out[k] = v);
		out.other = cntOther;
		return out;
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			propGroups: [
				Charactermancer_LanguageProficiencySelect.PROP_GROUP_,
				Charactermancer_ToolProficiencySelect.PROP_GROUP_,
			],
			modalTitle: "Language and Tool Proficiencies",
			title: "Language or Tool Proficiency",
			titlePlural: "Language and Tool Proficiencies",
			otherTitle: "(Other language or tool proficiency)",
			anyOptions: Charactermancer_LanguageToolProficiencySelect._ANY_OPTIONS,
		});
	}

	static get _ANY_OPTIONS () {
		return {
			any: [
				...Parser.LANGUAGES_ALL.map(it => ({prop: "languageProficiencies", prof: it})),
				...UtilActors.TOOL_PROFICIENCIES.map(it => ({prop: "toolProficiencies", prof: it})),
			],
			anyStandard: {
				isGrouped: true,
				valueGroups: [
					...Charactermancer_LanguageProficiencySelect.ANY_OPT_ANY_STANDARD__VALUE_GROUPS,
					{
						name: "Tools",
						values: UtilActors.TOOL_PROFICIENCIES.map(it => ({prop: "toolProficiencies", prof: it.toLowerCase()})),
					},
				],
			},
		};
	}
}

class Charactermancer_ArmorProficiencySelect extends Charactermancer_OtherProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		return this._getExistingProficiencies({
			existingProficienciesSetFvtt: existingFvtt?.armorProficiencies,
			vetToFvttProfs: UtilActors.VALID_ARMOR_PROFICIENCIES,
			allProfsVet: UtilActors.ARMOR_PROFICIENCIES,
		});
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			propGroups: [
				new Charactermancer_ProficiencySelect.PropGroup({
					prop: "armorProficiencies",
					propTrackerPulse: "pulseArmor",
					propTracker: "armor",
				}),
			],
			modalTitle: "Armor Proficiencies",
			title: "Armor Proficiency",
			titlePlural: "Armor Proficiencies",
			otherTitle: "(Other armor proficiency)",

			// TODO define a sort function, such that "shield|phb" is sorted after "light/med/heavy"

			// region TODO(Future) these do not support `propGroups`, and should be expanded to e.g. `fnsRenderKey`
			fnRenderKey: Charactermancer_ArmorProficiencySelect._renderKey.bind(Charactermancer_ArmorProficiencySelect),
			// endregion
		});
	}

	static _renderKey (key) {
		if (key === "light" || key === "medium" || key === "heavy") return key.toTitleCase();
		if (key === "shield|phb") return Renderer.get().render(`{@item shield|phb|Shields}`);
		return Renderer.get().render(`{@item ${key.split("|").map(sub => sub.toTitleCase()).join("|")}}`);
	}
}

class Charactermancer_WeaponProficiencySelect extends Charactermancer_OtherProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		return this._getExistingProficiencies({
			existingProficienciesSetFvtt: existingFvtt?.weaponProficiencies,
			vetToFvttProfs: UtilActors.VALID_WEAPON_PROFICIENCIES,
			allProfsVet: UtilActors.WEAPON_PROFICIENCIES,
		});
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.featureSourceTracker]
	 */
	constructor (opts) {
		super({
			...opts,
			propGroups: [
				new Charactermancer_ProficiencySelect.PropGroup({
					prop: "weaponProficiencies",
					propTrackerPulse: "pulseWeapons",
					propTracker: "weapons",
				}),
			],
			modalTitle: "Weapon Proficiencies",
			title: "Weapon Proficiency",
			titlePlural: "Weapon Proficiencies",
			otherTitle: "(Other weapon proficiency)",

			// region TODO(Future) these do not support `propGroups`, and should be expanded to e.g. `fnsRenderKey`
			fnRenderKey: it => Renderer.get().render(`{@item ${it.split("|").map(sub => sub.toTitleCase()).join("|")}}`),
			pFnGetParentGroup: it => DataConverter.pGetItemWeaponType(it),
			pFnGetChoice: ({filterExpression = null} = {}) => {
				const modalFilterItems = new ModalFilterItemsFvtt({
					filterExpression: filterExpression,
					namespace: "Charactermancer_WeaponProficiencySelect.items",
					isRadio: true,
				});
				return modalFilterItems.pGetUserSelection({filterExpression: filterExpression});
			},
			tag: `@item`,
			// endregion
		});
	}
}

class Charactermancer_ImmResVulnSelect extends BaseComponent {
	// region External
	/**
	 * @param opts
	 * @param opts.existingFvtt
	 * @param opts.available
	 */
	static async pGetUserInput (opts) {
		opts = opts || {};

		if (!opts.available) return {isFormComplete: true, data: {}};

		const comp = new this({...opts, existing: this.getExisting(opts.existingFvtt)});
		if (comp.isNoChoice()) return comp.pGetFormData();

		return UtilApplications.pGetImportCompModalFormData({comp});
	}

	/**
	 * @return {*}
	 */
	static getExisting () { throw new TypeError(`Unimplemented!`); }

	static isNoChoice (available) {
		let cntChoices = 0;
		DataConverter.WALKER_READONLY_GENERIC.walk(available, {object: (obj) => { if (obj.choose) cntChoices++; }});
		return cntChoices === 0;
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param opts.prop
	 * @param [opts.modalTitle]
	 * @param [opts.titleSingle]
	 * @param [opts.titlePlural]
	 */
	constructor (opts) {
		opts = opts || {};
		super();

		this._existing = opts.existing;
		this._available = opts.available;
		this._prop = opts.prop;
		this._modalTitle = opts.modalTitle;
		this._titlePlural = opts.titlePlural;
		this._titleSingle = opts.titleSingle;

		this._lastChoiceMeta = null;

		Object.assign(this.__state.readonly_selectedValues, this._getOutputObject());
	}

	get modalTitle () { return this._modalTitle; }

	render ($wrp) {
		this._lastChoiceMeta = {isActive: true, children: []};
		this._render_recurse($wrp, MiscUtil.copy(this._available), this._lastChoiceMeta, false);
	}

	_render_recurse ($wrp, arr, outMeta, isChoices) {
		const arrStrings = arr
			.filter(it => typeof it === "string")
			.sort(SortUtil.ascSortLower);

		if (!isChoices) {
			// Add one child per value, but only render one UI element for the lot.
			const staticValues = arrStrings
				.map(it => {
					outMeta.children.push({isActive: true, value: it});
					return it.toTitleCase();
				});
			$wrp.append(`<div>${staticValues.join(", ")}</div>`);
		} else {
			arrStrings
				.forEach(it => {
					const $cb = $(`<input type="checkbox" class="ml-1 mr-2">`)
						.change(() => {
							// On selecting, make sure we don't go over the selection limit
							if ($cb.prop("checked")) {
								const numChecked = outMeta.children.filter(it => it.isChoosable && it.isActive()).length;
								if (numChecked > outMeta.count) {
									const toDeActive = outMeta.lastChecked || outMeta.children.filter(it => it.isChoosable).last();
									toDeActive.setActive(false);
								}
								outMeta.lastChecked = node;
							} else {
								if (outMeta.lastChecked === node) outMeta.lastChecked = null;
							}

							this._state.readonly_selectedValues = this._getOutputObject();
						});

					const node = {
						isActive: () => $cb.prop("checked") ? it : null,
						value: it,
						isChoosable: true,
						setActive: (val) => $cb.prop("checked", val),
					};
					outMeta.children.push(node);

					return $$`<label class="py-1 stripe-even flex-v-center">
						${$cb}
						<span>${it.toTitleCase()}</span>
					</label>`.appendTo($wrp);
				});
		}

		arr
			.filter(it => typeof it !== "string")
			.forEach((it, i) => {
				if (!it.choose) throw new Error(`Unhandled immune/resist/vulnerability properties "${Object.keys(it).join(", ")}"`);

				if (isChoices) {
					// TODO this branch is completely untested
					// TODO this should be re-worked to use isChoosable/allow multiple choices as above

					// To set this particular choose group as active, de-activate all its peers, and activate itself
					const $btnSetActive = $(`<button class="btn btn-primary btn-5et btn-xs">Set Group Active</button>`)
						.click(() => {
							outMeta.children.forEach(it => it.isActive = false);
							nxtMeta.isActive = true;
							this._state.readonly_selectedValues = this._getOutputObject();
						});

					const nxtMeta = {isActive: false, children: []};

					const $wrpChoice = $(`<div class="flex-col my-1"></div>`);
					this._render_recurse($wrpChoice, it.choose.from, nxtMeta, true);

					$$`<div class="flex-col pl-2 stripe-even">
						<div class="flex-v-center my-1">${$btnSetActive}</div>
						${$wrpChoice}
					</div>`;

					return;
				}

				const count = it.choose.count || 1;
				const nxtMeta = {isActive: true, children: [], count, lastChecked: null};
				outMeta.children.push(nxtMeta);

				const $wrpChoice = $(`<div class="flex-col py-1 pt-0">
					${arrStrings.length || i > 0 ? `<hr class="hr-2 hr--dotted">` : ""}
					<div class="py-1">Choose ${count} ${count === 1 ? this._titleSingle : this._titlePlural}:</div>
				</div>`).appendTo($wrp);
				this._render_recurse($wrpChoice, it.choose.from, nxtMeta, true);
			});
	}

	isNoChoice () { return this.constructor.isNoChoice(this._available); }

	_getOutputSet () {
		const outSet = new Set(this._existing[this._prop] || []); // Copy the existing set

		if (this._lastChoiceMeta) this._getOutputSet_recurse(outSet, this._lastChoiceMeta);
		else DataConverter.WALKER_READONLY_GENERIC.walk(this._available, {string: (str) => { outSet.add(str); }});

		return outSet;
	}

	_getOutputSet_recurse (outSet, node) {
		if (!node.isActive) return;
		const isNodeActive = node.isActive === true || node.isActive();
		if (!isNodeActive) return;

		if (node.value) outSet.add(node.value);
		if (node.children) node.children.forEach(it => this._getOutputSet_recurse(outSet, it));
	}

	_getOutputObject () {
		return [...this._getOutputSet()].sort(SortUtil.ascSortLower).mergeMap(it => ({[it]: true}));
	}

	pGetFormData () {
		let isFormComplete = true;

		return {
			isFormComplete,
			data: {
				[this._prop]: MiscUtil.copy(this._state.readonly_selectedValues),
			},
		};
	}

	_getDefaultState () {
		return {
			readonly_selectedValues: {},
		};
	}
}

class Charactermancer_DamageImmunitySelect extends Charactermancer_ImmResVulnSelect {
	// region External
	static getExisting (existingFvtt) {
		return MiscUtil.copy([existingFvtt?.immune?.value || []]);
	}
	// endregion

	constructor (opts) {
		opts = opts || {};
		super({
			...opts,
			modalTitle: `Damage Immunities`,
			titlePlural: `Damage Immunities`,
			titleSingle: `Damage Immunity`,
			prop: "immune",
		});
	}
}

class Charactermancer_DamageResistanceSelect extends Charactermancer_ImmResVulnSelect {
	// region External
	static getExisting (existingFvtt) {
		return MiscUtil.copy([existingFvtt?.resist?.value || []]);
	}
	// endregion

	constructor (opts) {
		opts = opts || {};
		super({
			...opts,
			modalTitle: `Damage Resistances`,
			titlePlural: `Damage Resistances`,
			titleSingle: `Damage Resistance`,
			prop: "resist",
		});
	}
}

class Charactermancer_DamageVulnerabilitySelect extends Charactermancer_ImmResVulnSelect {
	// region External
	static getExisting (existingFvtt) {
		return MiscUtil.copy([existingFvtt?.vulnerable?.value || []]);
	}
	// endregion

	constructor (opts) {
		opts = opts || {};
		super({
			...opts,
			modalTitle: `Damage Vulnerabilities`,
			titlePlural: `Damage Vulnerabilities`,
			titleSingle: `Damage Vulnerability`,
			prop: "vulnerable",
		});
	}
}

class Charactermancer_ConditionImmunitySelect extends Charactermancer_ImmResVulnSelect {
	// region External
	static getExisting (existingFvtt) {
		return [existingFvtt?.conditionImmune?.value || []].map(it => it === "diseased" ? "disease" : it);
	}
	// endregion

	constructor (opts) {
		opts = opts || {};
		super({
			...opts,
			modalTitle: `Condition Immunities`,
			titlePlural: `Condition Immunities`,
			titleSingle: `Condition Immunity`,
			prop: "conditionImmune",
		});
	}
}

class Charactermancer_ExpertiseSelect extends Charactermancer_SkillSaveProficiencySelect {
	// region External
	static getExisting (existingFvtt) {
		const existingSkills = Object.entries(Charactermancer_SkillProficiencySelect.getExisting({skillProficiencies: existingFvtt.skillProficiencies}))
			.filter(([, profLevel]) => Number(profLevel) === 2)
			.mergeMap(([prof, profLevel]) => ({[prof]: profLevel}));

		// TODO(Future) add support for existing tool expertise when dnd5e sensibly supports
		const existingTools = {};

		// (These two sets should never collide)
		return {...existingSkills, ...existingTools};
	}

	static getOtherData ({actor = null} = {}) {
		if (!actor) return;
		return {
			existingSkillProficiencies: Charactermancer_SkillProficiencySelect.getExisting({skillProficiencies: MiscUtil.get(actor, "data", "data", "skills")}),
			existingToolProficiencies: Charactermancer_ToolProficiencySelect.getExisting({toolProficiencies: MiscUtil.get(actor, "data", "data", "traits", "toolProf")}),
		};
	}

	static isNoChoice (available) {
		if (!available?.length) return true; // If there's no data, there's no choice
		return available.length === 1
			&& !available[0].choose
			&& !available[0].anyProficientSkill
			&& !available[0].anyProficientTool;
	}
	// endregion

	/**
	 * @param opts
	 * @param opts.existing
	 * @param opts.available
	 * @param [opts.titlePrefix]
	 * @param [opts.featureSourceTracker]
	 * @param [opts.modalTitle]
	 */
	constructor (opts) {
		super({
			...opts,
			propGroup: new Charactermancer_ProficiencySelect.PropGroup({
				prop: "expertise",
				propTrackerPulse: "pulseExpertise",
				propTracker: "expertise",
			}),
			modalTitle: "Expertise",
			title: "Expertise",
			titlePlural: "Expertise",
		});
	}

	_getStaticDisplay (key) {
		if (Parser.SKILL_TO_ATB_ABV[key]) return Renderer.get().render(`{@skill ${key.toTitleCase()}}`);
		// If it's not a skill, then it's a tool (which we render as plain strings)
		return key.toTitleCase();
	}

	_getNonStaticDisplay (key, value) {
		switch (key) {
			case "anyProficientSkill": return `Choose ${value || 1} existing skill ${value > 1 ? "proficiencies" : "proficiency"}`;
			case "anyProficientTool": return `Choose ${value || 1} existing tool ${value > 1 ? "proficiencies" : "proficiency"}`;
			default: return super._getNonStaticDisplay(key, value);
		}
	}

	_getStaticKeys () {
		return this._available.map(profSet => Object.keys(profSet).filter(it => this._isStaticKey(it))).flat();
	}

	_isStaticKey (key) {
		return key !== "anyProficientSkill" && key !== "anyProficientTool";
	}

	_isSkillKey (key) {
		return key === "anyProficientSkill" || Object.keys(Parser.SKILL_TO_ATB_ABV).includes(key);
	}

	_hk_ixSet_renderPts (profSet) {
		this._lastMeta = {
			cleanup: () => {
				this._lastMeta._fnsCleanup.forEach(fn => fn());
			},
			_fnsCleanup: [],
		};

		const $ptsExistingStatic = Object.keys(profSet).some(it => this._isStaticKey(it)) ? this._render_renderPtStatic(this._$stgGroup, profSet) : null;
		let needsHr = $ptsExistingStatic != null;

		if (needsHr && profSet.anyProficientSkill) (needsHr = false) || this._$stgGroup.append(`<hr class="hr-2 hr--dotted">`);
		const $ptsExistingChooseAnyProficientSkill = profSet.anyProficientSkill ? this._render_renderPtChooseAnyProficientSkill(this._$stgGroup, profSet) : null;
		needsHr = needsHr || $ptsExistingChooseAnyProficientSkill != null;

		if (needsHr && profSet.anyProficientTool) (needsHr = false) || this._$stgGroup.append(`<hr class="hr-2 hr--dotted">`);
		const $ptsExistingChooseAnyProficientTool = profSet.anyProficientTool ? this._render_renderPtChooseAnyProficientTool(this._$stgGroup, profSet) : null;

		this._hkUpdateExisting = () => this._hk_updatePtsExisting($ptsExistingStatic, $ptsExistingChooseAnyProficientSkill, $ptsExistingChooseAnyProficientTool);
	}

	_getProps (ix) {
		return {
			propAnyProficientSkill: `ix_skill_${ix}`,
			propAnyProficientTool: `ix_tool_${ix}`,
		}
	}

	_render_renderPtChooseAnyProficientSkill ($stgGroup, profSet) {
		return this._render_renderPtChooseAnyProficient({
			$stgGroup,
			profSet,
			propProfSet: "anyProficientSkill",
			propIxProps: "propAnyProficientSkill",
			fnGetValues: this._getAvailableSkills.bind(this),
			propPulse: "pulseSkills",
			titleRow: "Existing Skill",
		});
	}

	_render_renderPtChooseAnyProficientTool ($stgGroup, profSet) {
		return this._render_renderPtChooseAnyProficient({
			$stgGroup,
			profSet,
			propProfSet: "anyProficientTool",
			propIxProps: "propAnyProficientTool",
			fnGetValues: this._getAvailableTools.bind(this),
			propPulse: "pulseTools",
			titleRow: "Existing Tool",
		});
	}

	_render_renderPtChooseAnyProficient (
		{
			$stgGroup,
			profSet,
			propProfSet,
			propIxProps,
			fnGetValues,
			propPulse,
			titleRow,
		},
	) {
		const numChoices = Number(profSet[propProfSet] || 1);

		const $wrp = $(`<div class="flex-col"></div>`).appendTo($stgGroup);

		const $ptsExisting = [];

		for (let i = 0; i < numChoices; ++i) {
			const ixProps = this._getProps(i);

			const selMeta = ComponentUiUtil.$getSelEnum(
				this,
				ixProps[propIxProps],
				{
					values: fnGetValues(),
					isAllowNull: true,
					asMeta: true,
					fnDisplay: it => it.toTitleCase(),
				},
			);
			this._lastMeta._fnsCleanup.push(selMeta.unhook);

			// region Juggle the "existing" display between multiple props
			const $ptExisting = $(`<div class="ve-small veapp__msg-warning inline-block no-wrap"></div>`);
			const hkMoveExisting = () => {
				const desiredLocationProf = this._state[ixProps[propIxProps]];
				const [curLocationProf, $curLocationArr] = Object.entries($ptsExisting)
					.find(([, $ptExistingArr]) => $ptExistingArr.includes($ptExisting)) || [];

				if (desiredLocationProf !== curLocationProf) {
					if ($curLocationArr) {
						const ixCurLocationArray = $curLocationArr.indexOf($ptExisting);
						if (~ixCurLocationArray) $curLocationArr.splice(ixCurLocationArray, 1);
						if (!$curLocationArr.length) delete $ptsExisting[curLocationProf];
					}
				}

				($ptsExisting[desiredLocationProf] = $ptsExisting[desiredLocationProf] || []).push($ptExisting);
			};
			this._addHookBase(ixProps[propIxProps], hkMoveExisting);
			this._lastMeta._fnsCleanup.push(() => this._removeHookBase(ixProps[propIxProps], hkMoveExisting));
			hkMoveExisting();
			// endregion

			const hk = () => selMeta.setValues(fnGetValues(), {isResetOnMissing: true});
			if (this._featureSourceTracker) {
				this._featureSourceTracker.addHook(this, propPulse, hk);
				this._lastMeta._fnsCleanup.push(() => this._featureSourceTracker.removeHook(this, propPulse, hk));

				// region Networking with other proficiency select components
				const hkSetTrackerInfo = () => this._doSetTrackerState();
				this._addHookBase(ixProps[propIxProps], hkSetTrackerInfo);
				this._lastMeta._fnsCleanup.push(() => this._removeHookBase(ixProps[propIxProps], hkSetTrackerInfo));
				// endregion
			}
			hk();

			this._lastMeta._fnsCleanup.push(() => delete this._state[ixProps[propIxProps]]);

			$$`<div class="flex-v-center ${i ? "mt-2" : ""}">
					<div class="mr-2 no-wrap">${titleRow}:</div>
					${selMeta.$sel}
					${$ptExisting}
				</div>`
				.appendTo($wrp);
		}

		return $ptsExisting;
	}

	_getAvailableSkills () {
		return this._getAvailableByType({
			propOtherData: "existingSkillProficiencies",
			propFeatureTracker: "skills",
		});
	}

	_getAvailableTools () {
		return this._getAvailableByType({
			propOtherData: "existingToolProficiencies",
			propFeatureTracker: "tools",
		});
	}

	_getAvailableByType (
		{
			propOtherData,
			propFeatureTracker,
		},
	) {
		// Available from sheet
		const out = new Set(Object.entries(this._otherData[propOtherData])
			.filter(([, profLevel]) => profLevel >= 1)
			.map(([prof]) => prof));

		// Available from other features
		if (this._featureSourceTracker) {
			(this._featureSourceTracker.getStatesForKey(propFeatureTracker, {ignore: this}) || [])
				.forEach(otherState => {
					Object.entries(otherState)
						.filter(([, isAvailable]) => isAvailable)
						.forEach(([prof]) => out.add(prof));
				});
		}

		return [...out].sort(SortUtil.ascSortLower);
	}

	_hk_updatePtsExisting ($ptsExistingStatic, $ptsExistingChooseAnyProficientSkill, $ptsExistingChooseAnyProficientTool) {
		const otherStates = this._featureSourceTracker ? this._featureSourceTracker.getStatesForKey(this._propGroup.propTracker, {ignore: this}) : null;

		const $ptsExistings = [$ptsExistingStatic, $ptsExistingChooseAnyProficientSkill, $ptsExistingChooseAnyProficientTool].filter(Boolean);

		$ptsExistings.forEach($ptsExisting => {
			Object.entries($ptsExisting)
				.forEach(([prof, $ptsExistingArr]) => {
					// Value from sheet
					let maxExisting = this._existing?.[prof] || 0;

					// Value from other networked components
					if (otherStates) otherStates.forEach(otherState => maxExisting = Math.max(maxExisting, otherState[prof] || 0));

					$ptsExistingArr.forEach($ptExisting => {
						$ptExisting
							.title(maxExisting === 2 ? "Expertise from Another Source" : "")
							.toggleClass("ml-1", maxExisting === 2)
							.html(maxExisting === 2 ? `(<i class="fas ${UtilActors.PROF_TO_ICON_CLASS[maxExisting]}"></i>)` : "");
					});
				});
		});
	}

	_doSetTrackerState () {
		const formData = this._getFormData();
		this._featureSourceTracker.setState(this, {
			[this._propGroup.propTracker]: formData.data?.[this._propGroup.prop],
			"skills": formData.data?.skillProficiencies,
			"tools": formData.data?.toolProficiencies,
		});
	}

	_getFormData () {
		const outSkills = {};
		const outTools = {};
		const outExpertise = {};

		let isFormComplete = true;

		const profSet = this._available[this._state.ixSet];

		Object.entries(profSet)
			.forEach(([k, v]) => {
				if (k === "anyProficientSkill" || k === "anyProficientTool") {
					const numChoices = Number(v || 1);
					for (let i = 0; i < numChoices; ++i) {
						const {propAnyProficientSkill, propAnyProficientTool} = this._getProps(i);
						const prop = this._isSkillKey(k) ? propAnyProficientSkill : propAnyProficientTool;
						const chosenProf = this._state[prop];
						if (chosenProf == null) return isFormComplete = false;
						(this._isSkillKey(k) ? outSkills : outTools)[chosenProf] = outExpertise[chosenProf] = 2;
					}
					return;
				}

				(this._isSkillKey(k) ? outSkills : outTools)[k] = outExpertise[k] = 2;
			})

		return {
			isFormComplete,
			data: {
				skillProficiencies: outSkills,
				toolProficiencies: outTools,
				expertise: outExpertise,
			},
		};
	}

	pGetFormData () { return this._getFormData(); }

	_getDefaultState () {
		return {
			ixSet: 0,
		};
	}
}

export {
	Charactermancer_FeatureSourceTracker,
	Charactermancer_AbilityScoreSelect,
	Charactermancer_Util,
	Charactermancer_SkillProficiencySelect,
	Charactermancer_LanguageProficiencySelect,
	Charactermancer_ToolProficiencySelect,
	Charactermancer_LanguageToolProficiencySelect,
	Charactermancer_ArmorProficiencySelect,
	Charactermancer_WeaponProficiencySelect,
	Charactermancer_SavingThrowProficiencySelect,
	Charactermancer_DamageImmunitySelect,
	Charactermancer_DamageResistanceSelect,
	Charactermancer_DamageVulnerabilitySelect,
	Charactermancer_ConditionImmunitySelect,
	Charactermancer_ExpertiseSelect,
};
