import {Vetools} from "./Vetools.js";
import {LGT, Util} from "./Util.js";
import {DataConverter} from "./DataConverter.js";
import {DataConverterSpell} from "./DataConverterSpell.js";
import {DataConverterClass} from "./DataConverterClass.js";
import {UtilActors} from "./UtilActors.js";
import {UtilApplications} from "./UtilApplications.js";
import {SharedConsts} from "../shared/SharedConsts.js";
import {Config} from "./Config.js";
import {UtilList2} from "./UtilList2.js";
import {ImportListFeat} from "./ImportListFeat.js";
import {ImportListCharacter} from "./ImportListCharacter.js";
import {
	Charactermancer_Class_HpIncreaseModeSelect,
	Charactermancer_Class_LevelSelect,
	Charactermancer_Class_ProficiencyImportModeSelect,
	Charactermancer_Class_StartingProficiencies,
	Charactermancer_Class_Util,
	PageFilterClassesFoundry,
} from "./UtilCharactermancerClass.js";
import {Charactermancer_StartingEquipment} from "./UtilCharactermancerEquipment.js";
import {Charactermancer_AdditionalSpellsSelect} from "./UtilCharactermancerAdditionalSpells.js";
import {UtilDataSource} from "./UtilDataSource.js";
import {UtilActiveEffects} from "./UtilActiveEffects.js";
import {Charactermancer_Util} from "./UtilCharactermancer.js";
import {UtilCompendium} from "./UtilCompendium.js";
import {UtilGameSettings} from "./UtilGameSettings.js";

class ImportListClass extends ImportListCharacter {
	// region External
	static init () {
		// If we drag-drop a Plutonium-created class/subclass, handle it via the class importer
		Hooks.on("preCreateItem", (item, itemData, options, itemId) => {
			if (item.parent?.documentName !== "Actor") return;

			const flags = itemData.flags?.[SharedConsts.MODULE_NAME_FAKE] || itemData.flags?.[SharedConsts.MODULE_NAME];
			if (!flags || !flags?.data?.class) return;

			const cls = flags.data.class;
			const sc = flags.data.subclass;

			const actor = item.parent;

			(async () => {
				const imp = new ImportListClass({actor});
				await imp.pInit();

				if (sc) {
					await imp.pImportSubclass(cls, sc, {filterValues: flags.filterValues});
				} else {
					await imp.pImportClass(cls, {filterValues: flags.filterValues});
				}
			})()
				.then(() => ui.notifications.info(`Imported "${cls.name}"${sc ? ` (${sc.name})` : ""} via Class Importer`))
				.catch(err => {
					ui.notifications.error(`Failed to import "${cls.name}"${sc ? ` (${sc.name})` : ""}! ${VeCt.STR_SEE_CONSOLE}`);
					throw err;
				});

			return false;
		});

		ImportListClass.SheetLevelUpButtonAdapter.init();
	}
	// endregion

	constructor (externalData) {
		externalData = externalData || {};
		super(
			{
				title: "Import Classes",
				template: `${SharedConsts.MODULE_LOCATION}/template/ImportListClass.hbs`,
				height: Util.getMaxWindowHeight(),
				resizable: true,
			},
			externalData,
			{
				namespace: "class_subclass",
				defaultFolderPath: ["Classes"],
				folderType: "Item",
				sidebarTab: "items",
				gameProp: "items",
				pageFilter: new PageFilterClassesFoundry(),
				isDedupable: true,
			},
		);

		this._cachedData = null;
	}

	async pSetContent (val) {
		await super.pSetContent(val);
		this._cachedData = null;
	}

	static async pPostLoad (data) {
		const out = await PageFilterClassesFoundry.pPostLoad(data);
		const optionalfeatureData = await Vetools.pGetWithCache(Vetools.DATA_URL_OPTIONALFEATURES);
		Charactermancer_Class_Util.addFauxOptionalFeatureFeatures(out, optionalfeatureData.optionalfeature);
		return out;
	}

	async pGetSources () {
		return [
			new UtilDataSource.DataSourceSpecial(
				Config.get("ui", "isStreamerMode") ? "SRD" : "5etools",
				Vetools.pGetClasses,
				{
					cacheKey: `5etools-classes`,
					pPostLoad: this.constructor.pPostLoad.bind(this.constructor),
					filterTypes: [UtilDataSource.SOURCE_TYP_OFFICIAL_ALL],
					isDefault: true,
				},
			),
			new UtilDataSource.DataSourceUrl(
				"Custom URL",
				"",
				{
					pPostLoad: this.constructor.pPostLoad.bind(this.constructor),
					filterTypes: [UtilDataSource.SOURCE_TYP_CUSTOM],
				},
			),
			new UtilDataSource.DataSourceFile(
				"Upload File",
				{
					pPostLoad: this.constructor.pPostLoad.bind(this.constructor),
					filterTypes: [UtilDataSource.SOURCE_TYP_CUSTOM],
				},
			),
			...(await Vetools.pGetHomebrewSources("class", "subclass")).map(({name, url}) => new UtilDataSource.DataSourceUrl(
				name,
				url,
				{
					pPostLoad: this.constructor.pPostLoad.bind(this.constructor),
					filterTypes: [UtilDataSource.SOURCE_TYP_BREW],
				},
			)),
		]
	}

	getData () {
		// On switching between actor/not actor importer, dump the cache
		if (this._cachedData && (this._cachedData.isRadio !== !!this._actor)) this._cachedData = null;

		this._cachedData = this._cachedData || {
			titleButtonRun: this._titleButtonRun,
			titleSearch: this._titleSearch,
			rows: this._content
				.sort((a, b) => SortUtil.ascSortLower(a.name, b.name) || SortUtil.ascSortLower(Parser.sourceJsonToFull(a.source || SRC_PHB), Parser.sourceJsonToFull(b.source || SRC_PHB)))
				.map((cls, ix) => {
					this._pageFilter.constructor.mutateForFilters(cls);

					return {
						name: cls.name,
						source: cls.source,
						sourceShort: Parser.sourceJsonToAbv(cls.source),
						sourceLong: Parser.sourceJsonToFull(cls.source),
						sourceClassName: Parser.sourceJsonToColor(cls.source),
						ix,
						disabled: !cls.classFeatures,
						subRows: (cls.subclasses || [])
							.map((sc, sx) => ({
								name: sc.name,
								source: sc.source || cls.source,
								sourceShort: Parser.sourceJsonToAbv(sc.source || cls.source),
								sourceLong: Parser.sourceJsonToFull(sc.source || cls.source),
								sourceClassName: Parser.sourceJsonToColor(sc.source || cls.source),
								ixSub: sx,
							})),
					}
				}),
		};

		if (this._actor) this._cachedData.isRadio = true;

		return this._cachedData;
	}

	_activateListeners_initRunButton () {
		this._$btnRun.click(async () => {
			if (!this._list) return;

			const selIds = this._actor
				? this._list.items
					.filter(it => it.data.tglSel && it.data.tglSel.classList.contains("active"))
					.map(it => ({ix: it.values.ix, ixSub: it.values.ixSub}))
				: this._list.items
					.filter(it => it.data.cbSel.checked)
					.map(it => ({ix: it.values.ix, ixSub: it.values.ixSub}));

			if (!selIds.length) return ui.notifications.warn(`Please select something to import!`);

			this.close();
			this.activateSidebarTab();

			const mapped = selIds.map(({ix, ixSub}) => {
				// Make a copy of the classes, so we can modify it later
				const cls = MiscUtil.copy(this._content[ix]);
				return {ix, cls, ixSub, sc: ixSub != null ? cls.subclasses[ixSub] : null}
			});
			// Wipe the subclass array from any pure classes. Any subclasses we add to this array later, will be imported
			mapped.filter(it => !it.sc).forEach(it => it.cls.subclasses = []);
			// Sort so that the pure classes are first
			mapped.sort((a, b) => !!a.sc - !!b.sc);

			const classes = [];
			const looseSubclasses = [];
			mapped.forEach(it => {
				if (it.sc) {
					const cls = classes.find(cls =>
						cls.name.toLowerCase() === it.sc.className.toLowerCase()
						&& cls.source.toLowerCase() === it.sc.classSource.toLowerCase(),
					);

					if (cls) cls.subclasses.push(it.sc);
					else looseSubclasses.push({cls: it.cls, sc: it.sc});
				} else classes.push(it.cls);
			});

			if (classes.length || looseSubclasses.length) await this._pHandleClickRunButton_pDoPreCachePack();

			const tasks = [
				...classes.map(cls => {
					return new Util.Task(
						`${cls.name} (${Parser.sourceJsonToAbv(cls.source)})`,
						() => this.pImportClass(cls),
					);
				}),
				...looseSubclasses.map(({cls, sc}) => {
					return new Util.Task(
						`${sc.name} (${Parser.sourceJsonToAbv(sc.source)})`,
						() => this.pImportSubclass(cls, sc),
					);
				}),
			];
			await UtilApplications.pRunTasks(tasks);

			if (!this._actor) game[this._gameProp].render();

			this._$cbAll.prop("checked", false).change();
		});
	}

	_activateListeners_pInitFilteredList () {
		// Init list library
		this._list = new List({
			$iptSearch: this._$iptSearch,
			$wrpList: this._$wrpList,
			fnSort: (a, b, opts) => {
				if (opts.sortDir === "desc" && a.values.ix === b.values.ix && (a.values.ixSub != null || b.values.ixSub != null)) {
					return a.values.ixSub != null ? -1 : 1;
				}

				return SortUtil.ascSortLower(a.values.sortName, b.values.sortName);
			},
		});
		SortUtil.initBtnSortHandlers(this._$wrpBtnsSort, this._list);

		const flatListItems = this._cachedData.rows.map(r => {
			const fromClass = {...r};
			delete fromClass.subRows;
			const fromSubclass = r.subRows.map(sr => ({...sr, ix: r.ix, className: r.name, classSourceLong: r.sourceLong, classSourceClassName: r.sourceClassName}));
			return [fromClass, ...fromSubclass];
		}).flat();

		return this._pageFilter.pInitFilterBox({
			$iptSearch: this._$iptSearch,
			$btnReset: this._$btnReset,
			$btnOpen: this._$bntFilter,
			$btnToggleSummaryHidden: this._$btnToggleSummary,
			$wrpMiniPills: this._$wrpMiniPills,
			namespace: this._getFilterNamespace(),
		}).then(() => {
			this._content.forEach(it => this._pageFilter.addToFilters(it));

			const optsListAbsorb = {
				fnGetName: it => it.name,
				// values used for sorting/search
				fnGetValues: it => {
					if (it.ixSub != null) {
						return {
							ix: it.ix,
							ixSub: it.ixSub,
							sortName: `${it.className} SOURCE ${it.classSourceLong} SUBCLASS ${it.name} SOURCE ${it.sourceLong}`,
							source: it.source,
						}
					} else {
						return {
							ix: it.ix,
							sortName: `${it.name} SOURCE ${it.sourceLong}`,
						}
					}
				},
			};

			if (this._actor) { // "Radio button" selection mode
				optsListAbsorb.fnGetData = (li) => ({
					tglSel: li.ele.firstElementChild.firstElementChild,
				});
				optsListAbsorb.fnBindListeners = listItem => {
					listItem.ele.addEventListener("click", () => {
						const isScItem = listItem.values.ixSub != null;
						const clsListItem = isScItem ? this._list.items.find(it => it.values.ix === listItem.values.ix && it.values.ixSub == null) : null;

						// region If clicking an item for the first time
						const actives = this._list.items.filter(it => it.data.tglSel.classList.contains("active"));
						if (!actives.length) {
							listItem.data.tglSel.classList.add("active");
							listItem.ele.classList.add("list-multi-selected");

							if (isScItem) {
								clsListItem.data.tglSel.classList.add("active");
								clsListItem.ele.classList.add("list-multi-selected");
							}

							return;
						}
						// endregion

						// region If re-clicking the currently selected item
						if (listItem.data.tglSel.classList.contains("active")) {
							listItem.data.tglSel.classList.remove("active");
							listItem.ele.classList.remove("list-multi-selected");

							if (isScItem) { // Deselect parent class item
								clsListItem.data.tglSel.classList.remove("active");
								clsListItem.ele.classList.remove("list-multi-selected");
							} else { // Deselect any subclass item
								actives.forEach(li => {
									li.data.tglSel.classList.remove("active");
									li.ele.classList.remove("list-multi-selected");
								});
							}

							return;
						}
						// endregion

						// region If clicking a different item to the one currently selected
						actives.forEach(li => {
							li.data.tglSel.classList.remove("active");
							li.ele.classList.remove("list-multi-selected");
						});

						listItem.data.tglSel.classList.add("active");
						listItem.ele.classList.add("list-multi-selected");

						if (isScItem) {
							clsListItem.data.tglSel.classList.add("active");
							clsListItem.ele.classList.add("list-multi-selected");
						}
						// endregion
					});
				};
			} else {
				optsListAbsorb.fnGetData = UtilList2.absorbFnGetData;
				optsListAbsorb.fnBindListeners = it => UtilList2.absorbFnBindListeners(this._list, it);
			}

			this._list.doAbsorbItems(flatListItems, optsListAbsorb);
			this._list.init();

			this._pageFilter.filterBox.render();

			this._pageFilter.filterBox.on(
				FilterBox.EVNT_VALCHANGE,
				this._handleFilterChange.bind(this),
			);

			this._handleFilterChange();
		});
	}

	_handleFilterChange () {
		const f = this._pageFilter.filterBox.getValues();
		this._list.filter(li => {
			const cls = this._content[li.values.ix];

			if (li.values.ixSub != null) {
				const sc = cls.subclasses[li.values.ixSub];
				// Both the subclass and the class must be displayed
				return this._pageFilter.toDisplay(f, cls) && this._pageFilter.filterBox.toDisplay(
					f,
					sc.source,
					sc._fMisc,
				);
			} else {
				return this._pageFilter.toDisplay(f, cls);
			}
		});
	}

	/**
	 * @param cls
	 * @param importOpts Options object.
	 * @param [importOpts.isTemp] if the item should be temporary, and displayed.
	 * @param [importOpts.filterValues] Saved filter values to be used instead of our own.
	 * @param [importOpts.isCharactermancer] If the call is coming from the charactermancer.
	 * @param [importOpts.levels] Pre-defined list of levels to import.
	 */
	async pImportClass (cls, importOpts) {
		importOpts = importOpts || {};

		console.log(...LGT, `Importing class "${cls.name}" (from "${Parser.sourceJsonToAbv(cls.source)}")`);

		if (DataConverterClass.isStubClass(cls)) return {imported: [], status: UtilApplications.TASK_EXIT_COMPLETE};

		if (importOpts.isTemp) return this._pImportClass_pImportToItems(cls, importOpts);
		else if (this._actor) return this._pImportClass_pImportToActor(cls, importOpts);
		else return this._pImportClass_pImportToItems(cls, importOpts);
	}

	/**
	 * @param cls A copy of the class data, which can be freely mutated.
	 * @param importOpts
	 */
	async _pImportClass_pImportToActor (cls, importOpts) {
		const dataBuilderOpts = new ImportListClass.ImportEntryOpts({
			isClassImport: true,
			isCharactermancer: importOpts.isCharactermancer,
		});

		// region Create one array of all class/subclass features
		let allFeatures;

		if (!cls._foundryAllFeatures) {
			allFeatures = Charactermancer_Class_Util.getAllFeatures(cls);

			// Filter features by source
			allFeatures = Charactermancer_Class_Util.getFilteredFeatures(allFeatures, this._pageFilter, importOpts.filterValues || this._pageFilter.filterBox.getValues());
		}
		// endregion

		const selectedLevelIndices = await this._pGetSelectedLevelIndices(cls, importOpts, allFeatures, dataBuilderOpts, true);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		await this._pValidateUserLevelIndices(selectedLevelIndices, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		dataBuilderOpts.targetLevel = Math.max(...selectedLevelIndices) + 1;
		dataBuilderOpts.numLevels = dataBuilderOpts.targetLevel - Math.min(...selectedLevelIndices);
		dataBuilderOpts.numLevelsPrev = UtilActors.getTotalClassLevels(this._actor);
		dataBuilderOpts.isIncludesLevelOne = selectedLevelIndices.includes(0);
		dataBuilderOpts.proficiencyImportMode = await this._pImportClass_pGetProficiencyImportMode(cls, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};
		dataBuilderOpts.hpIncreaseMode = await this._pImportClass_pGetHpImportMode(cls, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		// Arbitrarily select the first subclass for the purpose of importing (preferring those with spellcasting)
		const primarySubclass = cls.subclasses ? cls.subclasses[0] : null;
		const subclassesWithSpellcasting = (cls.subclasses || []).filter(it => it.spellcastingAbility || it.casterProgression);
		const casterSubclass = subclassesWithSpellcasting[0] || primarySubclass;
		dataBuilderOpts.spellcastingAbility = casterSubclass?.spellcastingAbility || cls.spellcastingAbility;

		const actUpdate = {
			data: {},
		};

		const curLevelMetaAndExistingClassItem = await this._pImportEntry_pGetCurLevelFillClassData(actUpdate, cls, casterSubclass, importOpts, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};
		const {curLevel, existingClassItem} = curLevelMetaAndExistingClassItem;

		this._pImportEntry_setActorFlags(actUpdate, cls, cls.subclasses, curLevel, dataBuilderOpts);

		await this._pImportEntry_pDoUpdateCharacter(actUpdate, cls, casterSubclass, curLevel, existingClassItem, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		if (cls.preparedSpells) await this._pImportPreparedCasterSpells(cls, primarySubclass, curLevel, dataBuilderOpts);

		await this._pImportEntry_pFillItemArrayAdditionalSpells(cls, cls.subclasses, curLevel, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		await this._pImportEntry_pAddUpdateClassItem(cls, primarySubclass, dataBuilderOpts);

		await this._pImportEntry_pHandleFeatures(cls, allFeatures, selectedLevelIndices, importOpts, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		await this._pImportEntry_pFinalise(dataBuilderOpts);

		if (this._actor.isToken) this._actor.sheet.render();

		return {status: UtilApplications.TASK_EXIT_COMPLETE};
	}

	async _pGetSelectedLevelIndices (cls, importOpts, allFeatures, dataBuilderOpts, isSubclass) {
		if (cls._foundrySelectedLevelIndices) return cls._foundrySelectedLevelIndices;

		// If there are pre-defined levels, map them to their relative index (which is always `level - 1`)
		if (importOpts.levels) return importOpts.levels.map(it => it - 1).filter(it => it >= 0);

		const indicesFormData = await Charactermancer_Class_LevelSelect.pGetUserInput({
			features: allFeatures,
			isSubclass,
			maxPreviousLevel: this._pImportEntry_getApproxPreviousMaxLevel(cls),
		});
		if (indicesFormData == null) return dataBuilderOpts.isCancelled = true;

		return indicesFormData.data;
	}

	/**
	 * Take a rough guess at what levels the character has already gained in this class(/subclass). We will use this to
	 * provide UI hints as to which levels the user shouldn't select when adding class levels.
	 */
	_pImportEntry_getApproxPreviousMaxLevel (cls) {
		const existingClassItems = this._getExistingClassItems(cls);
		if (!existingClassItems.length) return 0;
		return Math.max(...existingClassItems.map(it => it.data.data.levels || 0));
	}

	_pImportEntry_setActorFlags (actUpdate, cls, subclasses, curLevel, opts) {
		const flags = {dnd5e: {}};

		// (Note that these are now instead handled, generally, by active effects)

		if (Object.keys(flags.dnd5e).length) actUpdate.flags = flags;
	}

	async _pImportClass_pGetProficiencyImportMode (cls, opts) {
		const existingClassItems = this._actor.items.filter(it => it.type === "class");

		if (!opts.isClassImport || !opts.isIncludesLevelOne || !existingClassItems.length) return Charactermancer_Class_ProficiencyImportModeSelect.MODE_PRIMARY;

		// If specified externally, use this
		if (cls._foundryStartingProficiencyMode != null) return cls._foundryStartingProficiencyMode;

		const out = await Charactermancer_Class_ProficiencyImportModeSelect.pGetUserInput();
		if (out == null) opts.isCancelled = true;
		return out.data;
	}

	async _pImportClass_pGetHpImportMode (cls, opts) {
		if (!Charactermancer_Class_HpIncreaseModeSelect.isHpAvailable(cls)) return Charactermancer_Class_HpIncreaseModeSelect.MODE_DO_NOT_INCREASE;

		// If specified externally, use this
		if (cls._foundryHpIncreaseMode != null) return cls._foundryHpIncreaseMode;

		const out = await Charactermancer_Class_HpIncreaseModeSelect.pGetUserInput();
		if (out == null) return opts.isCancelled = true;
		if (out === VeCt.SYM_UI_SKIP) return Charactermancer_Class_HpIncreaseModeSelect.MODE_DO_NOT_INCREASE;
		return out.data;
	}

	async _pImportClass_pImportToItems (cls, importOpts) {
		const duplicateMeta = this._getDuplicateMeta({entity: cls});
		if (duplicateMeta.isSkip) return UtilApplications.TASK_EXIT_SKIPPED_DUPLICATE;

		const clsData = await DataConverterClass.pGetClassItem(cls, {isAddPermission: true, filterValues: this._pageFilter.filterBox.getValues()});

		if (importOpts.isTemp) {
			const clsItem = await Item.create(clsData, {renderSheet: true, temporary: true});
			const scItems = await (cls.subclasses || []).pSerialAwaitMap(sc => this._pImportSubclass_pImportToItems(cls, sc, importOpts));
			return {imported: [clsItem, ...scItems], status: UtilApplications.TASK_EXIT_COMPLETE};
		} else if (this._pack) {
			if (duplicateMeta.isOverwrite) {
				const clsItem = await this._pImportEntry_pDoUpdateExistingPackEntity(duplicateMeta, clsData);
				const scItems = await (cls.subclasses || []).pSerialAwaitMap(sc => this._pImportSubclass_pImportToItems(cls, sc, importOpts));
				return {imported: [clsItem, ...scItems], status: UtilApplications.TASK_EXIT_COMPLETE_UPDATE_OVERWRITE};
			}

			const clsItem = await Item.create(clsData);
			await this._pack.importDocument(clsItem);
			const scItems = await (cls.subclasses || []).pSerialAwaitMap(sc => this._pImportSubclass_pImportToItems(cls, sc, importOpts));
			return {imported: [clsItem, ...scItems], status: UtilApplications.TASK_EXIT_COMPLETE};
		} else {
			if (duplicateMeta.isOverwrite) {
				const clsItem = await this._pImportEntry_pDoUpdateExistingDirectoryEntity(duplicateMeta, clsData);
				const scItems = await (cls.subclasses || []).pSerialAwaitMap(sc => this._pImportSubclass_pImportToItems(cls, sc, importOpts));
				return {imported: [clsItem, ...scItems], status: UtilApplications.TASK_EXIT_COMPLETE_UPDATE_OVERWRITE};
			}

			const folderId = await this._pImportEntry_pGetFolderId(cls);
			if (folderId) clsData.folder = folderId;

			const clsItem = await Item.create(clsData, {renderSheet: false, temporary: false});

			await game.items.set(clsItem.id, clsItem);

			const scItems = await (cls.subclasses || []).pSerialAwaitMap(sc => this._pImportSubclass_pImportToItems(cls, sc, importOpts, {folderId}));
			return {imported: [clsItem, ...scItems], status: UtilApplications.TASK_EXIT_COMPLETE};
		}
	}

	/**
	 * @param cls
	 * @param sc
	 * @param importOpts Options object.
	 * @param [importOpts.isTemp] if the item should be temporary, and displayed.
	 * @param [importOpts.filterValues] Saved filter values to be used instead of our own.
	 */
	async pImportSubclass (cls, sc, importOpts) {
		importOpts = importOpts || {};

		console.log(...LGT, `Importing subclass "${sc.name}" (from "${Parser.sourceJsonToAbv(sc.source)}")`);

		if (DataConverterClass.isStubClass(cls)) return {imported: [], status: UtilApplications.TASK_EXIT_COMPLETE};
		if (DataConverterClass.isStubSubclass(sc)) return {imported: [], status: UtilApplications.TASK_EXIT_COMPLETE};

		if (importOpts.isTemp) return this._pImportSubclass_pImportToItems(cls, sc, importOpts);
		else if (this._actor) return this._pImportSubclass_pImportToActor(cls, sc, importOpts);
		else return this._pImportSubclass_pImportToItems(cls, sc, importOpts);
	}

	async _pImportSubclass_pImportToActor (cls, sc, importOpts) {
		const dataBuilderOpts = new ImportListClass.ImportEntryOpts({
			isClassImport: false,
			isCharactermancer: importOpts.isCharactermancer,
		});

		// region (Player) sanity check
		const existingClassItems = this._actor.items.filter(it => it.type === "class");
		if (!existingClassItems.length) {
			const isImportSubclassOnly = await InputUiUtil.pGetUserBoolean({
				title: "Import Class?",
				htmlDescription: "You have selected a subclass to import, but have no class levels. Would you like to import the class too?",
				textYes: "Import Class and Subclass",
				textNo: "Import Only Subclass",
			});

			if (isImportSubclassOnly == null) {
				dataBuilderOpts.isCancelled = true;
				return {status: UtilApplications.TASK_EXIT_CANCELLED};
			}

			// If the user chooses, switch to the class importer
			if (isImportSubclassOnly === true) {
				const cpyCls = MiscUtil.copy(cls);
				cpyCls.subclasses = [sc];
				return this.pImportClass(cpyCls, importOpts);
			}
		}
		// endregion

		// region Create one array of all class/subclass features
		let allFeatures = MiscUtil.copy(sc.subclassFeatures);
		allFeatures = Charactermancer_Class_Util.getFilteredFeatures(allFeatures, this._pageFilter, importOpts.filterValues || this._pageFilter.filterBox.getValues());
		// endregion

		const selectedLevelIndices = await this._pGetSelectedLevelIndices(cls, importOpts, allFeatures, dataBuilderOpts, true);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		await this._pValidateUserLevelIndices(selectedLevelIndices, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		dataBuilderOpts.targetLevel = Math.max(...selectedLevelIndices) + 1;
		dataBuilderOpts.numLevels = dataBuilderOpts.targetLevel - Math.min(...selectedLevelIndices);
		dataBuilderOpts.numLevelsPrev = UtilActors.getTotalClassLevels(this._actor);
		dataBuilderOpts.isIncludesLevelOne = false; // Always false for subclasses, as we never want to import level 1 proficiencies
		dataBuilderOpts.spellcastingAbility = sc.spellcastingAbility || cls.spellcastingAbility;
		dataBuilderOpts.proficiencyImportMode = await this._pImportClass_pGetProficiencyImportMode(cls, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		const actUpdate = {
			data: {},
		};

		const curLevelMetaAndExistingClassItem = await this._pImportEntry_pGetCurLevelFillClassData(actUpdate, cls, sc, importOpts, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};
		const {curLevel, existingClassItem} = curLevelMetaAndExistingClassItem;

		this._pImportEntry_setActorFlags(actUpdate, cls, [sc], curLevel, dataBuilderOpts);

		await this._pImportEntry_pDoUpdateCharacter(actUpdate, cls, sc, curLevel, existingClassItem, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		if (cls.preparedSpells) await this._pImportPreparedCasterSpells(cls, sc, curLevel, dataBuilderOpts);

		await this._pImportEntry_pFillItemArrayAdditionalSpells(cls, [sc], curLevel, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		await this._pImportEntry_pAddUpdateClassItem(cls, sc, dataBuilderOpts);

		await this._pImportEntry_pHandleFeatures(cls, allFeatures, selectedLevelIndices, importOpts, dataBuilderOpts);
		if (dataBuilderOpts.isCancelled) return {status: UtilApplications.TASK_EXIT_CANCELLED};

		await this._pImportEntry_pFinalise(dataBuilderOpts);

		if (this._actor.isToken) this._actor.sheet.render();

		return {status: UtilApplications.TASK_EXIT_COMPLETE};
	}

	/**
	 * @param cls
	 * @param sc
	 * @param importOpts
	 * @param [opts]
	 * @param [opts.folderId] If this is part of a class import, the folder ID the class data is being imported to.
	 */
	async _pImportSubclass_pImportToItems (cls, sc, importOpts, opts) {
		opts = opts || {};

		const scData = await DataConverterClass.pGetSubclassItem(cls, sc, {isAddPermission: true, filterValues: this._pageFilter.filterBox.getValues()});

		const duplicateMeta = this._getDuplicateMeta({name: scData.name, source: scData.data.source});
		if (duplicateMeta.isSkip) return {status: UtilApplications.TASK_EXIT_SKIPPED_DUPLICATE};

		if (importOpts.isTemp) {
			const imported = await Item.create(scData, {renderSheet: true, temporary: true});
			return {imported, status: UtilApplications.TASK_EXIT_COMPLETE};
		} else if (this._pack) {
			if (duplicateMeta.isOverwrite) return this._pImportEntry_pDoUpdateExistingPackEntity(duplicateMeta, scData);

			const scItem = await Item.create(scData);
			await this._pack.importDocument(scItem);
			return {imported: [scItem], status: UtilApplications.TASK_EXIT_COMPLETE};
		} else {
			if (duplicateMeta.isOverwrite) return this._pImportEntry_pDoUpdateExistingDirectoryEntity(duplicateMeta, scData);

			const folderId = opts.folderId || await this._pImportEntry_pGetFolderId(sc);
			if (folderId) scData.folder = folderId;

			const scItem = await Item.create(scData, {renderSheet: false, temporary: false});

			await game.items.set(scItem.id, scItem);

			return {imported: [scItem], status: UtilApplications.TASK_EXIT_COMPLETE};
		}
	}

	async _pImportEntry_pDoUpdateCharacter (actUpdate, cls, sc, curLevel, existingClassItem, opts) {
		// `existingClassItem` is the exact class features item the user wants us to import into, but pulling out the
		//    others allows us to calculate total spell slots
		const existingClassItems = this._actor.items.filter(it => it.type === "class");
		const otherExistingClassItems = existingClassItems.filter(it => it !== existingClassItem);

		// region xp
		const totalLevel = otherExistingClassItems
			.map(it => it.data.data.levels || 0)
			.reduce((a, b) => a + b, 0)
			+ (opts.targetLevel || 0);

		if (totalLevel > 0) {
			const xpObj = ((actUpdate.data.details = actUpdate.data.details || {}).xp = actUpdate.data.details.xp || {});
			const curXp = MiscUtil.get(this._actor, "data", "data", "details", "xp", "value") || 0;
			const tgtXp = Parser.LEVEL_XP_REQUIRED[totalLevel - 1];
			const nxtXp = Parser.LEVEL_XP_REQUIRED[Math.min(totalLevel, 19)];
			if (curXp < tgtXp) {
				xpObj.pct = 0;
				xpObj.value = tgtXp;
			} else {
				xpObj.pct = (curXp / nxtXp) * 100
			}
			xpObj.max = nxtXp;
		}
		// endregion

		// region proficiency bonus
		// Update proficiency if less than our target
		const curProfBonus = MiscUtil.get(this._actor, "data", "data", "attributes", "prof");
		const targetProf = Math.floor((opts.targetLevel - 1) / 4) + 2;
		if (curProfBonus < targetProf) (actUpdate.data.attributes = actUpdate.data.attributes || {}).prof = targetProf;
		// endregion

		// region Spellcasting
		const {
			casterProgression,
			spellAbility,
			totalSpellcastingLevels,
			maxPactCasterLevel,
		} = Charactermancer_Class_Util.getCasterProgression(cls, sc, {targetLevel: opts.targetLevel, otherExistingClassItems});

		if (totalSpellcastingLevels || casterProgression === "pact") {
			let anyMod = false;
			actUpdate.data.spells = actUpdate.data.spells || {};
			// Store a copy of the update to be applied after items have been applied--this allows us to correct any
			//   spell slot counts which get broken by setting a caster mode in a class item.
			const postDataSpells = MiscUtil.getOrSet(opts.postItemActorUpdate, "data", "spells", {});

			if (totalSpellcastingLevels) {
				// always use the "full" caster table, as other caster progressions are converted to overall full-caster levels
				const spellSlots = UtilActors.CASTER_TYPE_TO_PROGRESSION.full;
				const maxLevelSpells = spellSlots[totalSpellcastingLevels - 1] || spellSlots.last();

				maxLevelSpells.forEach((slots, i) => {
					if (slots === 0) return;
					const lvlProp = `spell${i + 1}`;

					const existingMax = MiscUtil.get(this._actor, "data", "data", "spells", lvlProp, "max");
					const existingValue = MiscUtil.get(this._actor, "data", "data", "spells", lvlProp, "value");
					if (existingMax != null) {
						if (existingMax < slots) {
							anyMod = true;

							const delta = slots - existingMax;

							actUpdate.data.spells[lvlProp] = {max: slots, value: existingValue + delta};
							postDataSpells[lvlProp] = {max: slots, value: existingValue + delta};
						}
					} else {
						anyMod = true;
						actUpdate.data.spells[lvlProp] = {max: slots, value: slots};
						postDataSpells[lvlProp] = {max: slots, value: slots};
					}
				});
			} else if (casterProgression === "pact") {
				const existingMax = MiscUtil.get(this._actor, "data", "data", "spells", "pact", "max");
				const existingValue = MiscUtil.get(this._actor, "data", "data", "spells", "pact", "value");

				const slots = UtilActors.CASTER_TYPE_TO_PROGRESSION.pact[maxPactCasterLevel - 1].find(Boolean);

				if (existingMax != null) {
					if (existingMax < slots) {
						anyMod = true;

						const delta = slots - existingMax;

						actUpdate.data.spells.pact = {max: slots, value: existingValue + delta};
						postDataSpells.pact = {max: slots, value: existingValue + delta};
					}
				} else {
					anyMod = true;
					actUpdate.data.spells.pact = {max: slots, value: slots};
					postDataSpells.pact = {max: slots, value: slots};
				}
			}

			if (!anyMod) {
				delete actUpdate.data.spells;
				delete opts.postItemActorUpdate.data.spells;
			}
		}

		if (spellAbility) (actUpdate.data.attributes = actUpdate.data.attributes || {}).spellcasting = spellAbility;
		// endregion

		// region HP
		if (opts.isClassImport && Charactermancer_Class_HpIncreaseModeSelect.isHpAvailable(cls)) {
			const conMod = Parser.getAbilityModNumber(Charactermancer_Util.getCurrentAbilityScores(this._actor).con); // Factor in effects when adding HP

			// You gain the hit points from your new class as described for levels after 1st.
			//   You gain the 1st-level hit points for a class only when you are a 1st-level character.
			//   --PHB, p163
			const isFirstHpGain = opts.isIncludesLevelOne && opts.proficiencyImportMode !== Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS;

			let hpIncrease = isFirstHpGain ? (cls.hd.number * cls.hd.faces) + conMod : 0;

			const numLevels = isFirstHpGain ? opts.numLevels - 1 : opts.numLevels;

			switch (opts.hpIncreaseMode) {
				case Charactermancer_Class_HpIncreaseModeSelect.MODE_TAKE_AVERAGE: {
					const avg = Math.ceil(cls.hd.number * ((cls.hd.faces + 1) / 2));
					hpIncrease += numLevels * Math.max((avg + conMod), 1);
					break;
				}

				case Charactermancer_Class_HpIncreaseModeSelect.MODE_ROLL: {
					for (let i = 0; i < numLevels; ++i) {
						const roll = new Roll(`${cls.hd.number}d${cls.hd.faces} + ${conMod}`);
						await roll.evaluate({async: true})
						hpIncrease += Math.max(roll.total, 1)
						// Post the roll to chat
						await roll.toMessage({
							flavor: `HP Increase`,
							speaker: {
								actor: this._actor.id,
								alias: this._actor.name,
								scene: null,
								token: null,
							},
						});
					}
					break;
				}

				case Charactermancer_Class_HpIncreaseModeSelect.MODE_DO_NOT_INCREASE: {
					hpIncrease = 0;
					break;
				}

				default: throw new Error(`Unhandled Hit Points increase mode "${opts.hpIncreaseMode}"`);
			}

			if (hpIncrease) {
				const {value: curValue, max: curMax} = Charactermancer_Util.getBaseHp(this._actor);

				switch (opts.proficiencyImportMode) {
					case Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS: {
						const hpCurNxt = curValue + hpIncrease;
						const hpMaxNxt = curMax + hpIncrease;

						MiscUtil.set(actUpdate, "data", "attributes", "hp", "value", hpCurNxt);
						MiscUtil.set(actUpdate, "data", "attributes", "hp", "max", hpMaxNxt);

						break;
					}

					case Charactermancer_Class_ProficiencyImportModeSelect.MODE_PRIMARY: {
						const hpCurNxt = (isFirstHpGain ? 0 : curValue) + hpIncrease;
						const hpMaxNxt = (isFirstHpGain ? 0 : curMax) + hpIncrease;

						MiscUtil.set(actUpdate, "data", "attributes", "hp", "value", hpCurNxt);
						MiscUtil.set(actUpdate, "data", "attributes", "hp", "max", hpMaxNxt);

						break;
					}

					case Charactermancer_Class_ProficiencyImportModeSelect.MODE_NONE: break;

					default: throw new Error(`Unknown proficiency import mode "${opts.proficiencyImportMode}"`);
				}
			}
		}
		// endregion

		// region Languages
		await DataConverter.pFillActorLanguageData(
			MiscUtil.get(this._actor, "data", "data", "traits", "languages"),
			cls.languageProficiencies,
			actUpdate.data,
			opts,
		);
		if (opts.isCancelled) return;
		// endregion

		if (Object.keys(actUpdate.data).length) await this._actor.update(actUpdate);
	}

	async _pImportEntry_pDoUpdateCharacter_pPopulateLevelOneProficienciesAndEquipment (actUpdate, cls, sc, opts) {
		const out = {
			chosenProficiencies: {},
		};

		// region saving throws; weapons/armor/tools
		out.chosenProficiencies = await this._pImportEntry_pDoUpdateCharacter_pPopulateProficienciesFrom(actUpdate, cls.startingProficiencies, cls.proficiency, Charactermancer_Class_ProficiencyImportModeSelect.MODE_PRIMARY, opts);
		if (opts.isCancelled) return;
		// endregion

		// region Equipment
		await this._pImportEntry_pDoUpdateCharacter_pPopulateEquipment(cls, opts);
		if (opts.isCancelled) return;
		// endregion

		return out;
	}

	async _pImportEntry_pDoUpdateCharacter_pPopulateProficienciesFrom (actUpdate, profs, savingThrowProfs, mode, opts) {
		const out = {
			skills: {},
		};

		// region Skills
		if (profs?.skills) {
			const skills = await DataConverter.pFillActorSkillData(
				MiscUtil.get(this._actor, "data", "data", "skills"),
				profs.skills,
				actUpdate.data,
				opts,
			);

			if (opts.isCancelled) return out;

			out.skills = skills; // Save the skills the user chose, so that we can add these to the final class item
		}
		// endregion

		// region Other proficiencies (armor, weapons, tools)
		const formDataOtherProfs = await Charactermancer_Class_StartingProficiencies.pGetUserInput({
			mode,
			primaryProficiencies: mode === Charactermancer_Class_ProficiencyImportModeSelect.MODE_PRIMARY ? profs : null,
			multiclassProficiencies: mode === Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS ? profs : null,
			savingThrowsProficiencies: savingThrowProfs,
			existingProficienciesFvttArmor: MiscUtil.get(this._actor, "data", "data", "traits", "armorProf"),
			existingProficienciesFvttWeapons: MiscUtil.get(this._actor, "data", "data", "traits", "weaponProf"),
			existingProficienciesFvttTools: MiscUtil.get(this._actor, "data", "data", "traits", "toolProf"),
			existingProficienciesFvttSavingThrows: Charactermancer_Class_StartingProficiencies.getExistingProficienciesFvttSavingThrows(this._actor),
		});
		if (formDataOtherProfs == null) return opts.isCancelled = true;
		if (formDataOtherProfs === VeCt.SYM_UI_SKIP) return;

		Charactermancer_Class_StartingProficiencies.applyFormDataToActorUpdate(actUpdate, formDataOtherProfs);
		// endregion

		return out;
	}

	async _pImportEntry_pDoUpdateCharacter_pPopulateMulticlassProficiencies (actUpdate, cls, sc, opts) {
		const out = {
			chosenProficiencies: {},
		};

		if (cls.multiclassing && cls.multiclassing.proficienciesGained) {
			out.chosenProficiencies = await this._pImportEntry_pDoUpdateCharacter_pPopulateProficienciesFrom(actUpdate, cls.multiclassing.proficienciesGained, null, Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS, opts);
			if (opts.isCancelled) return;
		}

		return out;
	}

	_getExistingClassItems (cls) { return Charactermancer_Class_Util.getExistingClassItems(this._actor, cls); }

	async _pImportEntry_pGetCurLevelFillClassData (actUpdate, cls, sc, importOpts, dataBuilderOpts) {
		const out = {
			curLevel: 0,
			existingClassItem: null,
		};

		let proficiencyMeta = {};

		if (dataBuilderOpts.isClassImport && dataBuilderOpts.isIncludesLevelOne) {
			switch (dataBuilderOpts.proficiencyImportMode) {
				case Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS: {
					proficiencyMeta = await this._pImportEntry_pDoUpdateCharacter_pPopulateMulticlassProficiencies(actUpdate, cls, sc, dataBuilderOpts);
					break;
				}
				case Charactermancer_Class_ProficiencyImportModeSelect.MODE_PRIMARY: {
					proficiencyMeta = await this._pImportEntry_pDoUpdateCharacter_pPopulateLevelOneProficienciesAndEquipment(actUpdate, cls, sc, dataBuilderOpts);
					if (dataBuilderOpts.isCancelled) return;
					break;
				}
				case Charactermancer_Class_ProficiencyImportModeSelect.MODE_NONE: {
					// Do nothing
					break;
				}
				default: throw new Error(`Unknown proficiency import mode "${dataBuilderOpts.proficiencyImportMode}"`);
			}
		}

		let description = await DataConverterClass.pGetRenderedClassTable(cls, sc);
		if (Config.get("importClass", "isImportDescription") && cls.fluff && cls.fluff.length) description += Renderer.get().setFirstSection(true).render({type: cls.fluff[0].type || "section", entries: cls.fluff[0].entries || []});

		// Find an existing item if it exist, and update
		const existingClassItems = this._getExistingClassItems(cls);

		const existingClassItem = await (async () => {
			if (!existingClassItems.length) return null;
			if (!sc && existingClassItems.length === 1) return existingClassItems[0];
			if (!sc) return this._pGetUserClassItem(cls, sc, existingClassItems);

			const isAnyExistingSubclass = existingClassItems.filter(it => it.data.data.subclass);
			if (!isAnyExistingSubclass.length && existingClassItems.length === 1) return existingClassItems[0];
			if (!isAnyExistingSubclass.length) return this._pGetUserClassItem(cls, sc, existingClassItems);

			const itemsWithMatchingSubclass = existingClassItems.filter(it => (it.data.data.subclass || "") === sc.name);
			if (!itemsWithMatchingSubclass.length) return this._pGetUserClassItem(cls, sc, existingClassItems);
			if (itemsWithMatchingSubclass.length === 1) return itemsWithMatchingSubclass[0];
			return this._pGetUserClassItem(cls, sc, itemsWithMatchingSubclass);
		})();

		if (existingClassItem) {
			let isUpdate = false;
			const update = {_id: existingClassItem.id};

			if (description && !(existingClassItem.data.data.description?.value || "".trim())) {
				isUpdate = true;
				((update.data = update.data || {}).description = update.data.description || {}).value = description;
			}

			let curLevel = existingClassItem.data.data.levels;
			if (curLevel) {
				if (dataBuilderOpts.targetLevel > curLevel) {
					isUpdate = true;
					(update.data = update.data || {}).levels = dataBuilderOpts.targetLevel;
				}
			}

			if (sc && sc.name) {
				const existingSubclassName = existingClassItem.data.data.subclass;
				if (existingSubclassName !== sc.name) {
					isUpdate = true;
					(update.data = update.data || {}).subclass = sc.name;
				}
			}

			if (isUpdate) dataBuilderOpts.classItemToUpdate = update;

			out.curLevel = curLevel || 0;
			out.existingClassItem = existingClassItem;
		} else {
			const casterProgression = (sc || {}).casterProgression || cls.casterProgression;
			const img = (await UtilCompendium.pGetCompendiumImage("class", cls)) || `modules/${SharedConsts.MODULE_NAME}/media/icon/laurels.svg`;
			const startingSkills = proficiencyMeta.chosenProficiencies && proficiencyMeta.chosenProficiencies.skills
				? Object.keys(proficiencyMeta.chosenProficiencies.skills)
				: [];

			// If there was no existing item, create a new class item
			dataBuilderOpts.classItemToCreate = {
				name: UtilApplications.getCleanEntityName(DataConverter.getNameWithSourcePart(cls, {isActorItem: true})),
				type: "class",
				data: {
					description: {
						value: description,
						chat: "",
						unidentified: "",
					},
					source: DataConverter.getSourceWithPagePart(cls),
					levels: dataBuilderOpts.targetLevel,
					subclass: sc ? sc.name : "",
					hitDice: DataConverterClass.getDataHitDice(cls),
					hitDiceUsed: 0,
					spellcasting: {
						progression: UtilActors.getMappedCasterType(casterProgression) || casterProgression,
						ability: dataBuilderOpts.spellcastingAbility,
					},
					skills: {
						number: dataBuilderOpts.proficiencyImportMode === Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS
							? (cls._cntStartingSkillChoicesMutliclass || 0)
							: (cls._cntStartingSkillChoices || 0),
						value: startingSkills,
					},
				},
				flags: this._getClassFlags(cls, sc, importOpts, dataBuilderOpts),
				img,
			};
		}

		if (Config.get("importClass", "isAddBaseAcActiveEffect")) {
			const isExisting = this._actor.effects.some(actEff => actEff.data.label === ImportListClass._AE_LABEL_BASE_AC && (!actEff.data.origin || /^Actor\.[^.]+$/.test(actEff.data.origin || "")));
			if (!isExisting) {
				const mode = CONST.ACTIVE_EFFECT_MODES.UPGRADE;
				dataBuilderOpts.effects.push(UtilActiveEffects.getGenericEffect({
					key: "data.attributes.ac.value",
					value: "10 + @abilities.dex.mod",
					mode,
					priority: UtilActiveEffects.getPriority(mode),
					label: ImportListClass._AE_LABEL_BASE_AC,
					icon: "icons/environment/people/commoner.webp",
				}));
			}
		}

		return out;
	}

	_getClassFlags (cls, sc, importOpts, dataBuilderOpts) {
		return {
			[SharedConsts.MODULE_NAME_FAKE]: {
				page: UrlUtil.PG_CLASSES,
				source: cls.source,
				hash: UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls),

				sourceSubclass: sc ? sc.source : null,
				hashSubclass: sc ? UrlUtil.URL_TO_HASH_BUILDER["subclass"](sc) : null,

				filterValues: importOpts.filterValues || this._pageFilter.filterBox.getValues(),

				isPrimaryClass: dataBuilderOpts.proficiencyImportMode === Charactermancer_Class_ProficiencyImportModeSelect.MODE_PRIMARY,
			},
		};
	}

	async _pGetUserClassItem (cls, sc, existingClassItems) {
		const titlePart = `${cls.name || "(Unnamed class)"}${sc ? ` (${sc.name || "(Unnamed subclass)"})` : ""}`;

		const ix = await InputUiUtil.pGetUserEnum({
			values: existingClassItems,
			placeholder: "Select Existing Feature...",
			title: `Select Existing Feature to Import ${titlePart} Levels To`,
			fnDisplay: fvIt => {
				if (fvIt == null) return `(Create New Feature)`;
				const scName = (fvIt.data.data.subclass || "").trim();
				return `${fvIt.name || "(Unnamed class)"}${scName ? `: ${scName}` : ""}`;
			},
			isAllowNull: true,
		});

		if (ix == null) return null;
		return existingClassItems[ix];
	}

	async _pImportPreparedCasterSpells (cls, sc, curLevel, dataBuilderOpts) {
		if (cls._foundryIsSkipImportPreparedSpells) return;

		const casterProgressionMeta = Charactermancer_Util.getCasterProgressionMeta({casterProgression: DataConverterClass.getMaxCasterProgression(cls.casterProgression, sc?.casterProgression), curLevel, targetLevel: dataBuilderOpts.targetLevel, isBreakpointsOnly: true});
		if (!casterProgressionMeta) return;

		const {spellLevelLow, spellLevelHigh, deltaLevels} = casterProgressionMeta;

		const doImport = await InputUiUtil.pGetUserBoolean({
			title: `Populate Spellbook`,
			htmlDescription: `<p>Do you want to populate the spellbook for this class (for class level${deltaLevels === 1 ? "" : "s"} ${deltaLevels === 1 ? dataBuilderOpts.targetLevel : `${curLevel + 1}-${dataBuilderOpts.targetLevel}`})?</p>`,
			textYes: "Yes",
			textNo: "No",
		});

		if (!doImport) return;

		const isBrewSource = sc ? BrewUtil.hasSourceJson(sc.source) : BrewUtil.hasSourceJson(cls.source);
		const isUaSource = !isBrewSource && (sc ? SourceUtil.isNonstandardSource(sc.source) : SourceUtil.isNonstandardSource(cls.source));

		const allSpells = (await Vetools.pGetAllSpells({
			isFilterNonStandard: !isUaSource,
			additionalSourcesBrew: isBrewSource ? this._getBrewSpellSources(cls, sc) : null,
		})).spell;

		const spellsToImport = Charactermancer_Class_Util.getPreparableSpells(allSpells, cls, spellLevelLow, spellLevelHigh);

		for (const spell of spellsToImport) {
			const hash = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_SPELLS](spell);

			const existingSpell = DataConverterSpell.getActorSpell(this._actor, spell.name, spell.source);
			if (existingSpell) {
				dataBuilderOpts.spellHashToItemIdMap[hash] = existingSpell.id;
				continue;
			}

			const spellData = await DataConverterSpell.pGetSpellItem(
				spell,
				{
					...UtilActors.getActorSpellItemOpts(),
					abilityAbv: dataBuilderOpts.spellcastingAbility,
				},
			);
			const addedItems = await this._actor.createEmbeddedDocuments("Item", [spellData], {});
			dataBuilderOpts.spellHashToItemIdMap[hash] = addedItems[0].id;
		}
	}

	_getBrewSpellSources (cls, sc) {
		const out = new Set();

		if (!Parser.SOURCE_JSON_TO_ABV[cls.source]) out.add(cls.source);
		if (sc && !Parser.SOURCE_JSON_TO_ABV[sc.source]) out.add(sc.source);

		if (cls.classSpells) {
			cls.classSpells
				.filter(it => it.source && !Parser.SOURCE_JSON_TO_ABV[it.source])
				.forEach(({source}) => out.add(source));
		}
		if (sc && sc.subclassSpells) {
			sc.subclassSpells
				.filter(it => it.source && !Parser.SOURCE_JSON_TO_ABV[it.source])
				.forEach(({source}) => out.add(source));
		}

		return [...out];
	}

	async _pImportEntry_pFillItemArrayAdditionalSpells (cls, subclasses, curLevel, opts) {
		// region From class
		const casterProgressionMeta = Charactermancer_Util.getCasterProgressionMeta({casterProgression: cls.casterProgression, curLevel, targetLevel: opts.targetLevel});

		const formData = await Charactermancer_AdditionalSpellsSelect.pGetUserInput({
			additionalSpells: cls.additionalSpells,
			sourceHintText: cls.name,
			curLevel: curLevel,
			targetLevel: opts.targetLevel,
			spellLevelLow: casterProgressionMeta ? casterProgressionMeta.spellLevelLow : null,
			spellLevelHigh: casterProgressionMeta ? casterProgressionMeta.spellLevelHigh : null,
		});

		if (formData == null) return opts.isCancelled = true;
		if (formData !== VeCt.SYM_UI_SKIP) {
			await Charactermancer_AdditionalSpellsSelect.pApplyFormDataToActor(
				this._actor,
				formData,
				{
					abilityAbv: opts.spellcastingAbility,
					hashToIdMap: opts.spellHashToItemIdMap,
				},
			);
		}
		// endregion

		// region From subclass
		for (const sc of subclasses) {
			const casterProgressionMeta = Charactermancer_Util.getCasterProgressionMeta({casterProgression: sc?.casterProgression || cls.casterProgression, curLevel, targetLevel: opts.targetLevel});

			const formData = await Charactermancer_AdditionalSpellsSelect.pGetUserInput({
				additionalSpells: sc.additionalSpells,
				sourceHintText: sc.name,
				curLevel: curLevel,
				targetLevel: opts.targetLevel,
				spellLevelLow: casterProgressionMeta ? casterProgressionMeta.spellLevelLow : null,
				spellLevelHigh: casterProgressionMeta ? casterProgressionMeta.spellLevelHigh : null,
			});

			if (formData == null) return opts.isCancelled = true;
			if (formData === VeCt.SYM_UI_SKIP) continue;

			await Charactermancer_AdditionalSpellsSelect.pApplyFormDataToActor(
				this._actor,
				formData,
				{
					abilityAbv: opts.spellcastingAbility,
					hashToIdMap: opts.spellHashToItemIdMap,
				},
			);
		}
		// endregion
	}

	async _pImportEntry_pHandleFeatures (cls, allFeatures, selectedLevelIndices, importOpts, dataBuilderOpts) {
		if (cls._foundryAllFeatures) {
			await this._pImportEntry_pFillItemArrayPredefinedFeatures({
				allPreloadedFeatures: cls._foundryAllFeatures,
				importOpts,
				dataBuilderOpts,
			});
			this._pImportEntry_handleConDifference({
				conInitial: cls._foundryConInitial,
				conFinal: cls._foundryConFinal,
				isConPreApplied: true,
				dataBuilderOpts,
			});
		} else {
			const allChosenFeatures = allFeatures.filter(f => selectedLevelIndices.includes(f.level - 1));
			const fillMeta = await this._pImportEntry_pFillItemArrayFeatures(allChosenFeatures, importOpts, dataBuilderOpts);
			if (dataBuilderOpts.isCancelled) return;
			this._pImportEntry_handleConDifference({
				conInitial: fillMeta.conInitial,
				conFinal: fillMeta.conFinal,
				dataBuilderOpts,
			});
		}
	}

	async _pImportEntry_pFillItemArrayPredefinedFeatures (
		{
			allPreloadedFeatures,
			importOpts,
			dataBuilderOpts,
		},
	) {
		// Add hook to render @spell tags as Foundry @Item[] links where possible
		const hook = DataConverterSpell.doHookSpellLinkRender.bind(null, this._actor.id, dataBuilderOpts.spellHashToItemIdMap);

		await Renderer.get().pWithPlugin({
			entryType: "link",
			pluginType: "*",
			fnPlugin: hook,
			pFn: async () => {
				const {ImportListClassFeature} = await import("./ImportListClassFeature.js");
				const {ImportListOptionalFeature} = await import("./ImportListOptionalFeature.js");

				const importListClassFeature = new ImportListClassFeature({actor: this._actor});
				await importListClassFeature.pInit();

				const importListOptionalFeature = new ImportListOptionalFeature({actor: this._actor});
				await importListOptionalFeature.pInit();

				for (const loaded of allPreloadedFeatures) {
					switch (loaded.type) {
						case "optionalfeature": {
							await importListOptionalFeature.pImportEntry(loaded.entity, {isCharactermancer: true});
							break;
						}
						case "classFeature":
						case "subclassFeature": {
							await importListClassFeature.pImportEntry(loaded.entity, {isCharactermancer: true, isLeaf: true, spellcastingAbilityAbv: dataBuilderOpts.spellcastingAbility});
							break;
						}
						default: throw new Error(`Unhandled feature type "${loaded.type}"`);
					}
				}
			},
		});
	}

	async _pImportEntry_pFillItemArrayFeatures (allFeatures, importOpts, dataBuilderOpts) {
		const conInitial = Charactermancer_Util.getCurrentAbilityScores(this._actor).con;

		// Add hook to render @spell tags as Foundry @Item[] links where possible
		const hook = DataConverterSpell.doHookSpellLinkRender.bind(null, this._actor.id, dataBuilderOpts.spellHashToItemIdMap);
		await Renderer.get().pWithPlugin({
			entryType: "link",
			pluginType: "*",
			fnPlugin: hook,
			pFn: async () => {
				const existingFeatureChecker = new Charactermancer_Class_Util.ExistingFeatureChecker(this._actor);

				const {ImportListClassFeature} = await import("./ImportListClassFeature.js");
				const importListClassFeature = new ImportListClassFeature({actor: this._actor});
				await importListClassFeature.pInit();

				for (const feature of allFeatures) {
					const lowName = (feature.name || "").toLowerCase().trim();
					if (lowName === "ability score improvement") {
						const abilityScoreIncrease = new ImportListClass.AbilityScoreIncrease(this._actor, feature.level, dataBuilderOpts);
						abilityScoreIncrease.render(true);

						const feat = await abilityScoreIncrease.pWaitForUserInput(); // This will set "opts.isCancelled" as appropriate
						if (feat) {
							const importListFeat = new ImportListFeat({actor: this._actor});
							await importListFeat.pImportEntry(
								feat,
								{
									isCharactermancer: importOpts.isCharactermancer,
								},
							);
						}
						continue;
					}

					await importListClassFeature.pImportEntry(
						feature,
						{
							isCharactermancer: importOpts.isCharactermancer,
							isPreLoadedFeature: true,
							featureEntriesPageFilter: this._pageFilter,
							featureEntriesPageFilterValues: importOpts.filterValues || this._pageFilter.filterBox.getValues(),
							existingFeatureChecker,
							spellcastingAbilityAbv: dataBuilderOpts.spellcastingAbility,
						},
					);
				}
			},
		});

		if (dataBuilderOpts.isCancelled) return;

		const conFinal = Charactermancer_Util.getCurrentAbilityScores(this._actor).con;
		return {conInitial, conFinal};
	}

	_pImportEntry_handleConDifference ({conInitial, conFinal, dataBuilderOpts, isConPreApplied}) {
		if (conInitial == null || conFinal == null || conFinal === conInitial) return;

		const modOld = Parser.getAbilityModNumber(conInitial);
		const modNew = Parser.getAbilityModNumber(conFinal);
		const hpIncrease = (dataBuilderOpts.numLevelsPrev + (isConPreApplied ? 0 : dataBuilderOpts.numLevels)) * (modNew - modOld);

		const {value: curValue, max: curMax} = Charactermancer_Util.getBaseHp(this._actor);

		const hpCurNxt = curValue + hpIncrease;
		const hpMaxNxt = curMax + hpIncrease;

		MiscUtil.set(dataBuilderOpts.actorUpdate, "data", "attributes", "hp", "value", hpCurNxt);
		MiscUtil.set(dataBuilderOpts.actorUpdate, "data", "attributes", "hp", "max", hpMaxNxt);
	}

	async _pImportEntry_pAddUpdateClassItem (cls, sc, dataBuilderOpts) {
		if (dataBuilderOpts.classItemToCreate) {
			const importedEmbeds = await UtilActors.pAddActorItems(this._actor, [dataBuilderOpts.classItemToCreate], {}, {addFeatures: false});

			if (await DataConverterClass.pHasClassSubclassSideLoadedEffects(this._actor, cls, sc)) {
				const importedEmbed = DataConverter.getImportedEmbed(importedEmbeds, dataBuilderOpts.classItemToCreate);

				if (importedEmbed) {
					const effectsToAdd = await DataConverterClass.pGetClassSubclassItemEffects(this._actor, cls, sc, importedEmbed.document);
					await UtilActors.pAddActorEffects(this._actor, effectsToAdd);
				}
			}
		}

		if (dataBuilderOpts.classItemToUpdate) {
			await this._actor.updateEmbeddedDocuments("Item", [dataBuilderOpts.classItemToUpdate], {addFeatures: false});
		}
	}

	async _pImportEntry_pFinalise (dataBuilderOpts) {
		// Copy over equipment's actor currency update, as required
		if (dataBuilderOpts.formDataEquipment?.data?.currency) MiscUtil.set(dataBuilderOpts.actorUpdate, "data", "currency", dataBuilderOpts.formDataEquipment.data.currency);

		await this._pDoMergeAndApplyActorUpdate(dataBuilderOpts.actorUpdate);

		// Import equipment
		await Charactermancer_StartingEquipment.pImportEquipmentItemEntries(this._actor, dataBuilderOpts.formDataEquipment);

		// Apply generic active effects
		if (dataBuilderOpts.effects.length) {
			await UtilActors.pAddActorEffects(this._actor, dataBuilderOpts.effects);
		}

		// Handle any final updates that need to be made after items have been added/updated
		Util.trimObject(dataBuilderOpts.postItemActorUpdate);
		if (Object.keys(dataBuilderOpts.postItemActorUpdate).length) await this._actor.update(dataBuilderOpts.postItemActorUpdate);
	}

	/**
	 * Check if the user has only selected one high level while not having any class levels. If so, they probably meant
	 * to import all levels up to and including that level, not just e.g. level 7. Give them a chance to fix their
	 * mistake.
	 */
	async _pValidateUserLevelIndices (indices, opts) {
		// If the user selected more than one index, don't question them
		if (indices.length > 1) return;

		// If the user selected 1st level, don't question them
		if (indices[0] === 0) return;

		// If the user already has class levels, don't question them
		const existingClassItems = this._actor.items.filter(it => it.type === "class");
		if (existingClassItems.length) return;

		const singleLevel = indices[0] + 1;
		const isSelectMissing = await InputUiUtil.pGetUserBoolean({
			title: "Import Lower Levels?",
			htmlDescription: `You have selected a single level to import (level ${singleLevel}). Would you like to import level${singleLevel === 2 ? "" : "s"} ${singleLevel === 2 ? "1" : `1-${singleLevel - 1}`} too?`,
			textYes: `Import Levels 1-${singleLevel}`,
			textNo: `Import Level ${singleLevel}`,
		});

		if (isSelectMissing == null) {
			opts.isCancelled = true;
			return;
		}

		if (isSelectMissing) {
			const maxIndex = indices[0];
			for (let i = 0; i <= maxIndex; ++i) {
				indices[i] = i;
			}
		}
	}

	async _pImportEntry_pDoUpdateCharacter_pPopulateEquipment (cls, opts) {
		if (!cls.startingEquipment) return;

		const startingEquipment = new Charactermancer_StartingEquipment({
			actor: this._actor,
			startingEquipment: cls.startingEquipment,
			appSubTitle: cls.name,
			equiSpecialSource: cls.source,
			equiSpecialPage: cls.page,
		});
		const formData = await startingEquipment.pWaitForUserInput();
		if (formData == null) return opts.isCancelled = true;
		if (formData === VeCt.SYM_UI_SKIP) return;

		opts.formDataEquipment = formData;
	}
}
ImportListClass._AE_LABEL_BASE_AC = "Base/Unarmored AC";

ImportListClass.ImportEntryOpts = class extends ImportListCharacter.ImportEntryOpts {
	/**
	 * @param [opts]
	 * @param [opts.isClassImport]
	 * @param [opts.isCharactermancer]
	 */
	constructor (opts) {
		super(opts);
		opts = opts || {};

		this.isClassImport = !!opts.isClassImport;

		this.actorUpdate = {}; // Note that this is not the main actor update; this is extra data populated from class features
		this.postItemActorUpdate = {}; // An update that will be applied after all item updates have been made. Useful for fixing values after item updates have broken them.
		this.spellHashToItemIdMap = {};

		this.classItemToCreate = null;
		this.classItemToUpdate = null;

		this.formDataEquipment = null;

		this.targetLevel = null; // Final level the actor will end up at
		this.numLevels = null; // Total number of levels we are to import
		this.numLevelsPrev = null; // Number of class levels the actor had before import
		this.isIncludesLevelOne = null;
		this.spellcastingAbility = null;
		this.proficiencyImportMode = null;
		this.hpIncreaseMode = null;
	}
};

ImportListClass.AbilityScoreIncrease = class extends Application {
	constructor (actor, level, dataBuilderOpts) {
		super({
			title: `Ability Score Improvement\u2014Level ${level}`,
			template: `${SharedConsts.MODULE_LOCATION}/template/ImportListClassAbilityScoreIncrease.hbs`,
			width: 640,
			resizable: true,
		});

		this._dataBuilderOpts = dataBuilderOpts;

		this._resolve = null;
		this._reject = null;
		this._pUserInput = new Promise((resolve, reject) => {
			this._resolve = resolve;
			this._reject = reject;
		});

		this._comp = new ImportListClass.AbilityScoreIncrease.Component(
			actor,
			dataBuilderOpts,
			this.close.bind(this),
		);
	}

	activateListeners ($html) {
		this._comp.render($html);
	}

	async close () {
		await super.close();
		if (!this._comp.isDataEntered) this._dataBuilderOpts.isCancelled = true;
		this._resolve(this._comp.getFeat());
	}

	pWaitForUserInput () { return this._pUserInput; }
};
ImportListClass.AbilityScoreIncrease.Component = class extends BaseComponent {
	constructor (actor, dataBuilderOpts, fnClose) {
		super();
		this._actor = actor;
		this._dataBuilderOpts = dataBuilderOpts;
		this._fnClose = fnClose;

		this._isDataEntered = false;

		Object.assign(
			this.__state,
			Charactermancer_Util.getBaseAbilityScores(this._actor),
		);
	}

	get isDataEntered () { return this._isDataEntered; }

	render ($html) {
		const $btnShowTabAsi = $(`<button class="btn btn-default w-50 btn-5et">Ability Score Improvement</button>`)
			.click(() => this._state.mode = "ability");
		const $btnShowTabFeat = $(`<button class="btn btn-default w-50 btn-5et">Feat</button>`)
			.click(() => this._state.mode = "feat");

		const $wrpTabAsi = $(`<div class="flex-col w-100 h-100"></div>`);
		const $wrpTabFeat = $(`<div class="flex-col w-100 h-100"></div>`);

		const hkMode = () => {
			const isAbilityMode = this._state.mode === "ability";
			$btnShowTabAsi.toggleClass("active", isAbilityMode)
			$btnShowTabFeat.toggleClass("active", !isAbilityMode);
			$wrpTabAsi.toggleVe(isAbilityMode);
			$wrpTabFeat.toggleVe(!isAbilityMode);
		};
		hkMode();
		this._addHookBase("mode", hkMode);

		this._render_ability($wrpTabAsi);
		this._render_feat($wrpTabFeat);

		$$($html)`<div class="flex-col w-100 h-100">
			<div class="flex no-shrink btn-group mb-1">${$btnShowTabAsi}${$btnShowTabFeat}</div>
			${$wrpTabAsi}
			${$wrpTabFeat}
		</div>`
	}

	_render_ability ($wrpTabAsi) {
		const rowMetas = [
			"str",
			"dex",
			"con",
			"int",
			"wis",
			"cha",
		].map(abil => {
			const $dispCur = $(`<div class="col-2 text-center"></div>`);
			const $dispCurMod = $(`<div class="col-2 text-center"></div>`);
			const hkBase = () => {
				$dispCur.text(this._state[abil]);
				$dispCurMod.text(Parser.getAbilityModifier(this._state[abil]));
			};
			this._addHookBase(abil, hkBase);
			hkBase();

			const propBonus = `${abil}Bonus`;
			const {$wrp: $wrpBonus, $ipt: $iptBonus} = ComponentUiUtil.$getIptNumber(
				this,
				propBonus,
				0,
				{
					min: 0,
					fallbackOnNaN: 0,
					html: `<input type="text" class="text-center" placeholder="0">`,
					asMeta: true,
					decorationRight: "ticker",
					decorationLeft: "spacer",
				},
			);
			$iptBonus.click(() => $iptBonus.select());

			const $dispTotal = $(`<div class="col-2 text-center"></div>`);
			const $dispTotalMod = $(`<div class="col-2 text-center"></div>`);
			const hkBonus = () => {
				const scoreTotal = this._state[abil] + this._state[propBonus];
				$dispTotal.text(scoreTotal);
				$dispTotalMod.text(Parser.getAbilityModifier(scoreTotal));
				$dispTotal.toggleClass("veapp__msg-error", scoreTotal > 20).title(scoreTotal > 20 ? `You can't increase an ability score above 20 using this feature.` : "");
			};
			this._addHookBase(propBonus, hkBonus);
			hkBonus();

			const $row = $$`<div class="flex w-100 my-1">
				<div class="col-1 text-right bold">${abil.toUpperCase()}</div>
				${$dispCur}
				${$dispCurMod}
				<div class="col-2">${$wrpBonus}</div>
				<div class="col-1 text-center">=</div>
				${$dispTotal}
				${$dispTotalMod}
			</div>`;

			return {
				$row,
				$iptBonus,
			};
		});

		const $dispRemain = $(`<div class="text-center" title="Remaining"></div>`);

		const hkBonuses = () => {
			const totalBonuses = [
				"strBonus",
				"dexBonus",
				"conBonus",
				"intBonus",
				"wisBonus",
				"chaBonus",
			].map(prop => this._state[prop]).reduce((a, b) => a + b, 0);

			const isInvalid = totalBonuses > 2;

			$dispRemain.text(`Remaining: ${2 - totalBonuses}`).toggleClass("veapp__msg-error", isInvalid);
			rowMetas.forEach(it => it.$iptBonus.toggleClass("form-control--error", isInvalid));
		};
		[
			"strBonus",
			"dexBonus",
			"conBonus",
			"intBonus",
			"wisBonus",
			"chaBonus",
		].forEach(prop => this._addHookBase(prop, hkBonuses));
		hkBonuses();

		const $btnAcceptAsi = $(`<button class="btn btn-primary mr-2">Confirm</button>`)
			.click(async () => {
				const total = [
					this._state.strBonus,
					this._state.dexBonus,
					this._state.conBonus,
					this._state.intBonus,
					this._state.wisBonus,
					this._state.chaBonus,
				].reduce((a, b) => a + b, 0);
				if (total !== 2) return ui.notifications.error(`Please enter a combination of ability score changes which adds up to two!`);

				await this._pDoResolve(true);
			});

		const $btnSkipAsi = $(`<button class="btn btn-default mr-3">Skip</button>`)
			.click(() => this._pDoResolve(VeCt.SYM_UI_SKIP));

		$$($wrpTabAsi)`
		<div class="flex w-100 my-1 bold">
			<div class="text-center col-1"></div>
			<div class="text-center col-2">Current</div>
			<div class="text-center col-2 ve-muted">Mod</div>
			<div class="text-center col-2 text-center">${$dispRemain}</div>
			<div class="text-center col-1"></div>
			<div class="text-center col-2">Result</div>
			<div class="text-center col-2 ve-muted">Mod</div>
		</div>
		<div class="flex-col w-100 h-100">
			${rowMetas.map(it => it.$row)}
		</div>
		<div class="flex-v-center flex-h-right w-100">${$btnAcceptAsi}${$btnSkipAsi}</div>
		`;
	}

	_render_feat ($wrpTabFeat) {
		const $btnSelectFeat = $(`<button class="btn btn-default btn-5et w-100 mr-2">Choose Feat</button>`)
			.click(async () => {
				const featData = await ImportListFeat.UserChoose.pGetUserChoice(
					{
						id: "feats-classAbilityScoreIncrease",
						name: "Feats",
						singleName: "Feat",

						wizardTitleWindow: "Select Source",
						wizardTitlePanel3: "Configure and Open List",
						wizardTitleButtonOpenImporter: "Open List",
					},
					"classAbilityScoreIncrease",
				);
				if (!featData) return;

				const {page, source, hash} = MiscUtil.get(featData, "flags", SharedConsts.MODULE_NAME_FAKE) || {};
				if (!page || !source || !hash) return;

				this._state.feat = await Renderer.hover.pCacheAndGet(page, source, hash);
			});

		const $dispFeat = $(`<div></div>`);
		const hkFeat = () => {
			$dispFeat.empty();

			if (!this._state.feat) return;
			$dispFeat.html(`<hr class="hr-1"><h3 class="mb-2 mt-0">Selected: ${this._state.feat.name}</h3>`);

			$dispFeat.empty();
			$$($dispFeat)`<hr class="hr-1">
			${Vetools.withUnpatchedDiceRendering(() => Renderer.hover.$getHoverContent_stats(UrlUtil.PG_FEATS, MiscUtil.copy(this._state.feat)))}`;
		}
		hkFeat();
		this._addHookBase("feat", hkFeat);

		const $btnAcceptFeat = $(`<button class="btn btn-primary btn-5et">Confirm</button>`)
			.click(async () => {
				if (!this._state.feat) return ui.notifications.error(`Please select a feat!`);
				await this._pDoResolve(true);
			});

		const $btnSkipFeat = $(`<button class="btn btn-default btn-5et">Skip</button>`)
			.click(() => this._pDoResolve(VeCt.SYM_UI_SKIP));

		$$($wrpTabFeat)`
		<div class="flex-col h-100">
			<div class="flex-v-center mb-1">
				${$btnSelectFeat}
				<div class="flex-v-center btn-group">${$btnAcceptFeat}${$btnSkipFeat}</div>
			</div>
			${$dispFeat}
		</div>
		`;
	}

	async _pDoResolve (isOutput) {
		if (!isOutput) return this._fnClose();

		if (isOutput === VeCt.SYM_UI_SKIP) {
			this._isDataEntered = true;
			return this._fnClose();
		}

		const actUpdate = this._getActorUpdate();
		if (actUpdate) {
			this._isDataEntered = true;
			await this._actor.update(actUpdate);
		}

		if (this.getFeat()) {
			this._isDataEntered = true;
		}

		this._fnClose();
	}

	_getActorUpdate () {
		if (this._state.mode !== "ability") return null;
		return {
			data: {
				abilities: {
					str: {value: this._state.str + this._state.strBonus},
					dex: {value: this._state.dex + this._state.dexBonus},
					con: {value: this._state.con + this._state.conBonus},
					int: {value: this._state.int + this._state.intBonus},
					wis: {value: this._state.wis + this._state.wisBonus},
					cha: {value: this._state.cha + this._state.chaBonus},
				},
			},
		}
	}

	getFeat () {
		if (this._state.mode !== "feat") return null;
		return MiscUtil.copy(this._state.feat);
	}

	_getDefaultState () {
		return {
			mode: "ability",

			str: 0,
			dex: 0,
			con: 0,
			int: 0,
			wis: 0,
			cha: 0,
			strBonus: 0,
			dexBonus: 0,
			conBonus: 0,
			intBonus: 0,
			wisBonus: 0,
			chaBonus: 0,

			feat: null,
		}
	}
}

ImportListClass.SheetLevelUpButtonAdapter = class {
	static init () {
		Hooks.on("renderActorSheet", (app, $html, data) => {
			this._pHandleRenderSheet(app, $html, data).then(null);
		});
	}

	static async _pHandleRenderSheet (app, $html, data) {
		if (!Config.get("importClass", "isAddLevelUpButton")) return;
		if (!data.owner) return;
		if (!data.isCharacter) return;

		const sheetAdapter = ImportListClass.SheetLevelUpButtonAdapter._SUPPORTED_SHEETS[app.constructor.name];

		if (!sheetAdapter) return;

		const minRole = Config.get("import", "minimumRole");
		if (game.user.role < minRole) return;

		const existingClassItems = app.actor.items.filter(it => it.type === "class");
		if (!existingClassItems.length) return;

		const availableClassesMetas = await existingClassItems.pSerialAwaitMap(existingClassItem => this._pGetAvailableClassSubclass(existingClassItem));

		const $btnLevelUp = !availableClassesMetas.some(it => !it.errors) ? this._$getBtnLevelUp_errors() : this._$getBtnLevelUp({app, availableClassesMetas});

		sheetAdapter.addButton(app.form, $btnLevelUp);
	}

	static _$getBtn () {
		return $(`<button class="btn btn-5et btn-xxs ml-1 imp-cls__btn-sheet-level-up flex-vh-center" type="button"><i class="fas fa-arrow-alt-circle-up mx-auto pl-1p"></i></button>`);
	}

	static _$getBtnLevelUp_errors () {
		return this._$getBtn()
			.prop("disabled", true)
			.title(`Cannot Level Up\u2014no known classes found.`);
	}

	static _$getBtnLevelUp ({app, availableClassesMetas}) {
		const isGlowing = !UtilGameSettings.getSafe(game.system.id, "disableExperienceTracking") && UtilActors.isLevelUp(app.actor);

		return this._$getBtn()
			.toggleClass("btn-pulse", !!isGlowing)
			.title(`Level Up`)
			.click(async () => {
				const {$modalInner, doClose} = UiUtil.getShowModal({
					title: `Level Up`,
					isHeaderBorder: true,
					isMaxWidth640p: true,
					isMinHeight0: true,
				});

				const $rows = availableClassesMetas.map(({errors, existingClassItem, cls, sc, filterValues}) => {
					if (errors) {
						return $$`<div class="flex-col stripe-even py-1 ve-muted">
							<p>${(existingClassItem.name || "").qq()}${(existingClassItem.data.data.subclass || "").trim() ? ` (${existingClassItem.data.data.subclass.qq()})` : ""} was not available for level up:</p>${errors.map(it => `<p><i>${it.qq()}</i></p>`).join("")}
						</div>`;
					}

					const $btnChoose = $(`<button class="btn btn-5et btn-sm no-wrap" title="Level up this class"><i class="fas fa-arrow-alt-circle-up"></i> Level ${existingClassItem.data.data.levels} &#8594; Level ${existingClassItem.data.data.levels + 1}</button>`)
						.click(async () => {
							doClose();

							const targetLevel = Number(existingClassItem.data.data.levels) + 1;

							if (!sc) {
								const minSubclassLevel = Math.min(...cls.classFeatures
									.filter(it => it.gainSubclassFeature && it.classFeature)
									.map(it => DataUtil.class.unpackUidClassFeature(it.classFeature).level));
								if (targetLevel >= minSubclassLevel) {
									const isSelectSubclass = await InputUiUtil.pGetUserBoolean({
										title: `Choose Subclass?`,
										htmlDescription: `Would you like to choose a subclass to include in the level up?`,
										textYes: "Yes",
										textNo: "No",
									});

									if (isSelectSubclass) {
										const modalFilterClasses = new ModalFilterClasses({namespace: `${ModalFilterClasses.name}.selectSubclass`});
										const selected = await modalFilterClasses.pGetUserSelection({ // Returns "raw" data
											selectedClass: cls,
											isClassDisabled: true,
										});
										if (selected) sc = selected.subclass;
									}
								}
							}

							const importList = new ImportListClass({actor: app.actor});
							await importList.pInit();

							cls = MiscUtil.copy(cls);
							delete cls.subclasses; // (Ensure no legacy subclass junk goes through)

							const importableClassData = await ImportListClass.pPostLoad({class: [cls], subclass: [sc].filter(Boolean)});
							await importList.pImportClass(importableClassData[0], {levels: [targetLevel], filterValues});
						});

					return $$`<div class="flex-v-center stripe-even py-1">
						<div class="w-100 mr-2">${existingClassItem.name}${sc && existingClassItem.data.data.subclass ? ` (${existingClassItem.data.data.subclass})` : ""}</div>
						<div>${$btnChoose}</div>
					</div>`;
				});

				const $btnAddNewLevel = $(`<button class="btn btn-5et btn-sm no-wrap"><i class="fas fa-plus-circle"></i> Add New Class (Multiclass)</button>`)
					.click(async () => {
						doClose();

						const modalFilterClasses = new ModalFilterClasses({namespace: `${ModalFilterClasses.name}.selectSubclass`});
						const selected = await modalFilterClasses.pGetUserSelection({});
						if (!selected?.class) return;

						const importList = new ImportListClass({actor: app.actor});
						await importList.pInit();

						const clsRaw = await Renderer.hover.pCacheAndGet("raw_class", selected.class.source, UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES]({name: selected.class.name, source: selected.class.source}), {isCopy: true});
						const scRaw = selected.subclass ? await Renderer.hover.pCacheAndGet("raw_subclass", selected.subclass.source, UrlUtil.URL_TO_HASH_BUILDER["subclass"]({name: selected.subclass.name, shortName: selected.subclass.shortName, source: selected.subclass.source, className: selected.subclass.className, classSource: selected.subclass.classSource}), {isCopy: true}) : null;

						delete clsRaw.subclasses; // (Ensure no legacy subclass junk goes through)

						const importableClassData = await ImportListClass.pPostLoad({class: [clsRaw], subclass: [scRaw].filter(Boolean)});

						const clsOut = importableClassData[0];
						clsOut._foundryStartingProficiencyMode = Charactermancer_Class_ProficiencyImportModeSelect.MODE_MULTICLASS;

						await importList.pImportClass(importableClassData[0], {levels: [1], filterValues: (await ImportListClass.SheetLevelUpButtonAdapter.pGetDefaultFilterValues())});
					});

				$$`<div class="flex-col h-100 w-100 mt-2">
						<div class="mb-1">Your current classes are listed below.</div>
						<div class="mb-1">If you level a class which does not have a subclass, and is elligeble for one, you will be prompted to choose a subclass.</div>
						<div>${Renderer.get().render(`Alternatively, you may gain a level in a new class, if your DM allows {@variantrule multiclassing|phb}.`)}</div>
						<hr class="hr-2">
						<div class="w-100">
							${$rows}
						</div>
						<hr class="hr-1 hr--dotted">
						<div class="mt-1 mb-2 flex-h-right">${$btnAddNewLevel}</div>
					</div>`.appendTo($modalInner);
			});
	}

	static async _pCacheClassData () {
		ImportListClass.SheetLevelUpButtonAdapter._P_LOADING_CLASS_DATA = ImportListClass.SheetLevelUpButtonAdapter._P_LOADING_CLASS_DATA || (async () => {
			ImportListClass.SheetLevelUpButtonAdapter._CACHE_CLASS_DATA = await DataUtil.class.loadRawJSON();
		})();
		await ImportListClass.SheetLevelUpButtonAdapter._P_LOADING_CLASS_DATA;
	}

	static async _pGetAvailableClassSubclass (existingClassItem) {
		const levelNumber = Number(existingClassItem.data.data.levels);
		if (isNaN(levelNumber)) return {existingClassItem, errors: [`Class item "${existingClassItem.name}" (${existingClassItem.id}) did not have a valid "levels" value!`]};

		const flags = existingClassItem?.data?.flags?.[SharedConsts.MODULE_NAME_FAKE];

		const filterValues = await this._pGetFilterValues(flags);

		const isClassDefinedInFlags = flags && flags.page && flags.source && flags.hash;
		if (!isClassDefinedInFlags) return this._pGetAvailableClassSubclassFromName(existingClassItem, filterValues);

		const cls = await Renderer.hover.pCacheAndGet("raw_class", flags.source, flags.hash, {isCopy: true});
		const sc = flags.sourceSubclass && flags.hashSubclass
			? (await Renderer.hover.pCacheAndGet("raw_subclass", flags.sourceSubclass, flags.hashSubclass, {isCopy: true}))
			: null;

		if (flags.sourceSubclass && flags.hashSubclass && !sc) return {existingClassItem, errors: [`Failed to find and/or load subclass "${flags.hashSubclass}" (${flags.sourceSubclass}) from flags`]};

		if (cls) return {existingClassItem, cls, sc, filterValues};

		if (isClassDefinedInFlags) return {existingClassItem, errors: [`Failed to find and/or load class "${flags.hash}" (${flags.source}) from flags`]};

		return this._pGetAvailableClassSubclassFromName(existingClassItem, filterValues);
	}

	static async _pGetAvailableClassSubclassFromName (existingClassItem, filterValues) {
		await this._pCacheClassData();

		const cls = Charactermancer_Class_Util.getClassFromExistingClassItem(existingClassItem, ImportListClass.SheetLevelUpButtonAdapter._CACHE_CLASS_DATA.class);

		if (!cls) return {existingClassItem, errors: [`Failed to find and/or load class "${existingClassItem.name}" (${DataConverter.getItemSource(existingClassItem)}) by name`]};

		const cleanSubclassName = (existingClassItem.data.data.subclass || "").trim();
		const sc = cleanSubclassName
			? Charactermancer_Class_Util.getSubclassFromExistingClassItem(existingClassItem, cls, ImportListClass.SheetLevelUpButtonAdapter._CACHE_CLASS_DATA.subclass)
			: null;

		if (cleanSubclassName && sc == null) return {existingClassItem, errors: [`Failed to find and/or load subclass "${existingClassItem.data.data.subclass}" by name`]};

		return {existingClassItem, cls, sc, filterValues};
	}

	static async _pGetFilterValues (flags) {
		if (flags?.filterValues) return flags.filterValues;

		// If there are no filter values present in the flags, we'll have to improvise--silently load up a class filter
		//   selection modal and use it as a surrogate.
		return this.pGetDefaultFilterValues();
	}

	static async pGetDefaultFilterValues () {
		const modalFilterClasses = new ModalFilterClasses({namespace: `${ModalFilterClasses.name}.default`});
		await modalFilterClasses.pPreloadHidden();
		return modalFilterClasses.pageFilter.filterBox.getValues();
	}

	static registerSupportedSheet (sheetName, ClassAdapter) {
		ImportListClass.SheetLevelUpButtonAdapter._SUPPORTED_SHEETS[sheetName] = ClassAdapter;
	}
};
ImportListClass.SheetLevelUpButtonAdapter._P_LOADING_CLASS_DATA = null;
ImportListClass.SheetLevelUpButtonAdapter._CACHE_CLASS_DATA = null;

ImportListClass.SheetLevelUpButtonAdapter._SUPPORTED_SHEETS = {};

ImportListClass.SheetLevelUpButtonAdapter.SheetAdapter = class {
	static addButton (form) { throw new Error(`Unimplemented!`); }
}

ImportListClass.SheetLevelUpButtonAdapter.SheetAdapterActorSheet5eCharacter = class extends ImportListClass.SheetLevelUpButtonAdapter.SheetAdapter {
	static addButton (form, $btn) {
		if (!this._INDEX_PATH) {
			const $ele = $(form).find(`.charlevel`);
			this._INDEX_PATH = ElementUtil.getIndexPathToParent(form, $ele[0]);
		}

		const tgt = ElementUtil.getChildByIndexPath(form, this._INDEX_PATH);
		$btn.css({fontSize: 10.5});
		$(tgt).append($btn);
	}
}
ImportListClass.SheetLevelUpButtonAdapter.SheetAdapterActorSheet5eCharacter._INDEX_PATH = null;
ImportListClass.SheetLevelUpButtonAdapter.registerSupportedSheet(
	"ActorSheet5eCharacter",
	ImportListClass.SheetLevelUpButtonAdapter.SheetAdapterActorSheet5eCharacter,
);

ImportListClass.SheetLevelUpButtonAdapter.Tidy5eSheet = class extends ImportListClass.SheetLevelUpButtonAdapter.SheetAdapter {
	static addButton (form, $btn) {
		if (!this._INDEX_PATH) {
			const $ele = $(form).find(`.level-information`);
			this._INDEX_PATH = ElementUtil.getIndexPathToParent(form, $ele[0]);
		}

		const tgt = ElementUtil.getChildByIndexPath(form, this._INDEX_PATH);
		$btn.addClass("ml-2 self-flex-center b-0");
		$(tgt).prepend($btn);
	}
}
ImportListClass.SheetLevelUpButtonAdapter.Tidy5eSheet._INDEX_PATH = null;
ImportListClass.SheetLevelUpButtonAdapter.registerSupportedSheet(
	"Tidy5eSheet",
	ImportListClass.SheetLevelUpButtonAdapter.Tidy5eSheet,
);

export {ImportListClass};
