export class BaseNotes {
	name = "";

	equal(other) {
		if (other == null) return false;
		// Weapons in 40k have unique names
		return this.name === other.name;
	}
}

/** A `selection` attached to a unit or model. */
export class Upgrade extends BaseNotes {
	cost = new Costs();
	count = 1;

	getSelectionName() {
		return this.name;
	}

	toString() {
		let string = this.getSelectionName();
		if (this.count > 1) string = `${this.count}x ${string}`;
		if (this.cost.hasValues()) string += ` ${this.cost.toString()}`;
		return string;
	}
}

/** A weapon `profile` that is under a `selection`. */
export class Weapon extends Upgrade {
	selectionName = "";

	range = "";
	type = "";
	str = "user";
	ap = "";
	damage = "";

	abilities = "";

	/**
	 * Name of this weapon's `selection`. This is different from name() because
	 * name() is used for sorting and deduping weapon profiles.
	 */
	getSelectionName() {
		return this.selectionName || this.name;
	}
}

export class WoundTracker extends BaseNotes {
	name = "";
	table = new Map();
}

export class Explosion extends BaseNotes {
	name = "";
	diceRoll = "";
	distance = "";
	mortalWounds = "";
}

export class Psyker extends BaseNotes {
	cast = "";
	deny = "";
	powers = "";
	other = "";
}

export class PsychicPower extends BaseNotes {
	name = "";
	manifest = 0;
	range = "";
	details = "";
}

export const UnitRole = {
	NONE: "NONE",

	// 40k
	Character: "Character",
	Battleline: "Battleline",
	DedicatedTransport: "TR",
};

export const UnitRoleSorting = {
	Character: 0,
	Battleline: 1,
	DedicatedTransport: 2,
	NONE: 3,
};

export class Model extends BaseNotes {
	count = 0;

	// Characteristics
	move = '0"';
	ws = "";
	bs = "";
	str = 4;
	toughness = 4;
	wounds = 1;
	attacks = "";
	leadership = 7;
	save = "";

	rangedWeapons = [];
	meleeWeapons = [];
	upgrades = [];
	// TODO model upgrades (i.e. tau support systems)
	psyker = null;
	psychicPowers = [];
	explosions = [];

	equal(model) {
		if (model == null) return false;

		if (
			this.name === model.name &&
			this.count === model.count &&
			this.rangedWeapons.length === model.rangedWeapons.length &&
			this.meleeWeapons.length === model.meleeWeapons.length &&
			this.upgrades.length === model.upgrades.length
		) {
			for (let wi = 0; wi < this.rangedWeapons.length; wi++) {
				if (!this.rangedWeapons[wi].equal(model.rangedWeapons[wi])) {
					return false;
				}
			}
			for (let wi = 0; wi < this.meleeWeapons.length; wi++) {
				if (!this.meleeWeapons[wi].equal(model.meleeWeapons[wi])) {
					return false;
				}
			}
			for (let wi = 0; wi < this.upgrades.length; wi++) {
				if (!this.upgrades[wi].equal(model.upgrades[wi])) {
					return false;
				}
			}

			// TODO: check for the same psychic powers
			if (this.psyker != null || model.psyker != null) return false;

			return true;
		}
		return false;
	}

	nameAndGear() {
		let name = this.name;

		if (
			this.meleeWeapons.length > 0 ||
			this.rangedWeapons.length > 0 ||
			this.upgrades.length > 0
		) {
			const gear = this.getDedupedWeaponsAndUpgrades();
			name += ` (${gear.map((u) => u.toString()).join(", ")})`;
		}
		return name;
	}

	getDedupedWeaponsAndUpgrades() {
		const deduped = [];

		for (const upgrade of [
			...this.rangedWeapons,
			...this.meleeWeapons,
			...this.upgrades,
		]) {
			if (
				!deduped.some(
					(e) => upgrade.getSelectionName() === e.getSelectionName(),
				)
			) {
				deduped.push(upgrade);
			}
		}
		return deduped;
	}

	normalize() {
		this.rangedWeapons.sort(CompareWeapon);
		this.meleeWeapons.sort(CompareWeapon);
		this.upgrades.sort(CompareObj);

		this.normalizeUpgrades(this.rangedWeapons);
		this.normalizeUpgrades(this.meleeWeapons);
		this.normalizeUpgrades(this.upgrades);
	}

	normalizeUpgrades(upgrades) {
		for (let i = 0; i < upgrades.length - 1; i++) {
			const upgrade = upgrades[i];
			if (upgrade.name === upgrades[i + 1].name) {
				upgrade.count += upgrades[i + 1].count;
				upgrade.cost.add(upgrades[i + 1].cost);
				upgrades.splice(i + 1, 1);
				i--;
			}
		}
		for (let upgrade of upgrades) {
			if (upgrade.count % this.count == 0) {
				upgrade.count /= this.count;
				upgrade.cost.points /= this.count;
			}
		}
	}
}

export class Unit extends BaseNotes {
	role = UnitRole.NONE;
	factions = new Set();
	keywords = new Set();

	abilities = {};
	rules = new Map();

	models = [];
	modelStats = [];
	modelList = [];
	rangedWeapons = [];
	meleeWeapons = [];
	spells = [];
	psykers = [];
	explosions = [];

	cost = new Costs();

	woundTracker = [];

	nameWithExtraCosts() {
		const extraCosts = []; // Track extra costs like cabal points.
		for (const freeformCostType in this.cost.freeformValues) {
			if (this.cost.freeformValues[freeformCostType] === 0) continue;
			extraCosts.push(
				`${this.cost.freeformValues[freeformCostType]}${freeformCostType}`,
			);
		}
		return extraCosts.length
			? `${this.name} [${extraCosts.join(", ")}]`
			: this.name;
	}

	equal(unit) {
		if (unit == null) return false;

		if (
			unit.name === this.name &&
			unit.role === this.role &&
			unit.models.length === this.models.length &&
			unit.modelStats.length === this.modelStats.length
		) {
			for (let mi = 0; mi < this.models.length; mi++) {
				if (!this.models[mi].equal(unit.models[mi])) {
					return false;
				}
			}

			for (let mi = 0; mi < this.modelStats.length; mi++) {
				if (!this.modelStats[mi].equal(unit.modelStats[mi])) {
					return false;
				}
			}

			// Check how to replace without lodash
			/* if (!.isEqual(this.abilities, unit.abilities)) {
        return false;
      } else if (!.isEqual(this.rules, unit.rules)) {
        return false;
      } */

			return true;
		}
		return false;
	}

	normalize() {
		// Sort force units by role and name
		this.models.sort(CompareModel);
		this.modelStats = this.modelStats.sort((a, b) => {
			var wounds_order =
				(isNaN(parseInt(a.wounds)) ? -1 : parseInt(a.wounds)) -
				(isNaN(parseInt(b.wounds)) ? -1 : parseInt(b.wounds));
			var leadership_order = parseInt(a.leadership) - parseInt(b.leadership);
			var move_order = parseInt(a.move) - parseInt(b.move);
			return (
				-wounds_order ||
				-leadership_order ||
				-move_order ||
				b.name.length - a.name.length
			);
		});

		for (let model of this.models) {
			model.normalize();
		}

		for (let i = 0; i < this.models.length - 1; i++) {
			const model = this.models[i];

			if (model.nameAndGear() === this.models[i + 1].nameAndGear()) {
				model.count++;
				this.models.splice(i + 1, 1);
				i--;
			}
		}

		for (let i = 0; i < this.modelStats.length - 1; i++) {
			const model = this.modelStats[i];

			if (model.equal(this.modelStats[i + 1])) {
				this.modelStats.splice(i + 1, 1);
				i--;
			}
		}

		this.modelList = this.models.map(
			(model) =>
				(model.count > 1 ? `${model.count}x ` : "") + model.nameAndGear(),
		);
		this.rangedWeapons = this.models
			.map((m) => m.rangedWeapons)
			.reduce((acc, val) => acc.concat(val), [])
			.sort(CompareWeapon)
			.filter((weap, i, array) => weap.name !== array[i - 1]?.name);
		this.meleeWeapons = this.models
			.map((m) => m.meleeWeapons)
			.reduce((acc, val) => acc.concat(val), [])
			.sort(CompareWeapon)
			.filter((weap, i, array) => weap.name !== array[i - 1]?.name);
		this.spells.push(
			...this.models
				.map((m) => m.psychicPowers)
				.reduce((acc, val) => acc.concat(val), []),
		);
		this.psykers.push(...this.models.map((m) => m.psyker).filter((p) => p));
		this.explosions.push(
			...this.models
				.map((m) => m.explosions)
				.reduce((acc, val) => acc.concat(val), []),
		);
	}
}

export class Force extends BaseNotes {
	catalog = "";
	faction = "Unknown";
	factionRules = new Map();
	configurations = [];
	rules = new Map();
	units = [];
}

export class Roster40k extends BaseNotes {
	cost = new Costs();
	forces = [];
	gameType = "";
}

export class Costs {
	commandPoints = 0;
	points = 0;
	freeformValues;

	hasValues() {
		return this.commandPoints !== 0 || this.points !== 0;
	}

	toString() {
		const values = [];
		if (this.points !== 0) values.push(`${this.points} pts`);
		if (this.commandPoints !== 0) values.push(`${this.commandPoints} CP`);
		return `[${values.join(" / ")}]`;
	}

	add(other) {
		this.commandPoints += other.commandPoints;
		this.points += other.points;
		for (const name in other.freeformValues) {
			this.addFreeformValue(name, other.freeformValues[name]);
		}
	}

	addFreeformValue(name, value) {
		if (!this.freeformValues) this.freeformValues = {};
		const oldValue = this.freeformValues[name] || 0;
		this.freeformValues[name] = oldValue + value;
	}
}

export function Create40kRoster10th(doc, gameType) {
	// Determine roster type (game system).
	let info = doc.querySelector("roster");
	if (info) {
		const roster = new Roster40k();
		roster.gameType = gameType;
		const name = info.getAttributeNode("name")?.nodeValue;
		if (name) {
			roster.name = name;
		} else {
			roster.name = "40k Army Roster";
		}

		ParseRosterPoints(doc, roster);
		ParseForces(doc, roster);

		return roster;
	}
}

function ParseRosterPoints(doc, roster) {
	let costs = doc.querySelectorAll("roster>costs>cost");
	for (let cost of costs) {
		roster.cost.add(ParseCost(cost));
	}
}

function ParseForces(doc, roster) {
	let forcesRoot = doc.querySelectorAll("roster>forces>force");
	for (let root of forcesRoot) {
		if (root.hasAttribute("name") && root.hasAttribute("catalogueName")) {
			let f = new Force();

			let which = root.getAttributeNode("name")?.nodeValue;
			let value = root.getAttributeNode("catalogueName")?.nodeValue;

			if (which) {
				f.name = which;
			}
			if (value) {
				f.catalog = value;
			}

			// TODO: Determine force faction and faction specific rules.

			// Only include the allegiance rules once.
			if (!DuplicateForce(f, roster)) {
				const rules = root.querySelectorAll("force>rules>rule");
				for (let rule of rules) {
					ExtractRuleDescription(rule, f.rules);
				}
			}

			ParseSelections(root, f);

			roster.forces.push(f);
		}
	}
}

function ParseSelections(root, force) {
	let selections = root.querySelectorAll("force>selections>selection");

	for (let selection of selections) {
		// What kind of selection is this
		let selectionName = selection.getAttributeNode("name")?.nodeValue;
		if (!selectionName) continue;

		if (selectionName.includes("Detachment Command Cost")) {
			// Ignore Detachment Command cost
		} else if (
			selectionName === "Battle Size" ||
			selectionName === "Gametype"
		) {
			ParseConfiguration(selection, force);
		} else if (selection.querySelector('profile[typeName="Unit"]')) {
			const unit = ParseUnit(selection);
			force.units.push(unit);
			for (const entry of unit.rules.entries()) {
				force.rules.set(entry[0], entry[1]);
			}
		} else if (selection.getAttribute("type") === "upgrade") {
			ExtractRuleFromSelection(selection, force.rules);
			ParseConfiguration(selection, force);
			const props = selection.querySelectorAll("selections>selection");
			for (let prop of props) {
				// sub-faction
				const name = prop.getAttribute("name");
				if (name && prop.getAttribute("type") === "upgrade") {
					if (force.faction === "Unknown") {
						// pick the first upgrade we see
						force.faction = name;
					}
					ExtractRuleFromSelection(prop, force.factionRules);
				}
			}
		} else {
			console.log("** UNEXPECTED SELECTION **", selectionName, selection);
		}
	}

	for (const key of force.factionRules.keys()) {
		force.rules.delete(key);
	}

	// Sort force units by role and name#
	force.units.sort((a, b) => {
		if (UnitRoleSorting[a.role] > UnitRoleSorting[b.role]) return 1;
		else if (UnitRoleSorting[a.role] == UnitRoleSorting[b.role]) {
			if (a.name > b.name) return 1;
			else if (a.name == b.name) return 0;
			return -1;
		}
		return -1;
	});
}

function ParseConfiguration(selection, force) {
	const name = selection.getAttribute("name");
	if (!name) {
		return;
	}
	const category = selection.querySelector("category")?.getAttribute("name");
	const subSelections = selection.querySelectorAll("selections>selection");
	const details = [];
	let costs = GetSelectionCosts(selection);
	for (const sel of subSelections) {
		details.push(sel.getAttribute("name"));
		costs.add(GetSelectionCosts(sel));
	}

	let configuration =
		!category || category === "Configuration" ? name : `${category} - ${name}`;
	if (details.length > 0) configuration += `: ${details.join(", ")}`;
	if (costs.hasValues()) configuration += ` ${costs.toString()}`;

	force.configurations.push(configuration);
}

function DuplicateForce(force, roster) {
	if (!roster || !force) return false;

	for (let f of roster.forces) {
		if (f.catalog === force.catalog) return true;
	}
	return false;
}

function ExtractRuleFromSelection(root, map) {
	const profiles = root.querySelectorAll("profiles>profile");
	for (const profile of profiles) {
		// detachment rules
		const profileName = profile.getAttribute("name");
		if (!profileName) continue;

		const profileType = profile.getAttribute("typeName");
		if (
			profileType === "Abilities" ||
			profileType === "Dynastic Code" ||
			profileType === "Household Tradition"
		) {
			ParseProfileCharacteristics(profile, profileName, profileType, map);
		}
	}

	const rules = root.querySelectorAll("rules>rule");
	for (const rule of rules) {
		ExtractRuleDescription(rule, map);
	}
}

function ExtractRuleDescription(rule, map) {
	const ruleName = rule.getAttribute("name");
	const desc = rule.querySelector("description");
	if (ruleName && desc?.textContent) {
		map.set(ruleName, desc.textContent);
	}
}

function LookupRole(roleText) {
	switch (roleText) {
		case "Character":
			return UnitRole.Character;
		case "Battleline":
			return UnitRole.Battleline;
		case "Dedicated Transport":
			return UnitRole.DedicatedTransport;
	}
	return UnitRole.NONE;
}

function ExpandBaseNotes(root, obj) {
	obj.name = root.getAttributeNode("name")?.nodeValue;
	return obj.name;
}

function ExtractNumberFromParent(root) {
	// Get parent node (a selection) to determine model count.
	if (root.parentElement && root.parentElement.parentElement) {
		const parentSelection = root.parentElement.parentElement;
		const countValue = parentSelection.getAttributeNode("number")?.nodeValue;
		if (countValue) {
			return +countValue;
		}
	}

	return 0;
}

function GetImmediateSelections(root) {
	// querySelectorAll(':scope > tagname') doesn't work with jsdom, so we hack
	// around it: https://github.com/jsdom/jsdom/issues/2998
	const selections = [];
	for (const child of root.children) {
		if (child.tagName === "selections") {
			for (const subChild of child.children) {
				if (subChild.tagName === "selection") {
					selections.push(subChild);
				}
			}
		}
	}
	return selections;
}

function HasImmediateProfileWithTypeName(root, typeName) {
	// querySelectorAll(':scope > tagname') doesn't work with jsdom, so we hack
	// around it: https://github.com/jsdom/jsdom/issues/2998
	for (const child of root.children) {
		if (child.tagName === "profiles") {
			for (const subChild of child.children) {
				if (
					subChild.tagName === "profile" &&
					subChild.getAttribute("typeName") === typeName
				) {
					return true;
				}
			}
		}
	}
	return false;
}

function GetSelectionCosts(selection) {
	// querySelectorAll(':scope > tagname') doesn't work with jsdom, so we hack
	// around it: https://github.com/jsdom/jsdom/issues/2998

	const costs = new Costs();
	for (const child of selection.children) {
		if (child.tagName === "costs") {
			for (const subChild of child.children) {
				costs.add(ParseCost(subChild));
			}
		}
	}
	return costs;
}

function ParseCost(cost) {
	const costs = new Costs();
	const which = cost.getAttribute("name");
	const value = cost.getAttribute("value");
	if (which && value) {
		if (which === "pts") {
			costs.points += +value;
		} else if (which === "CP") {
			costs.commandPoints += +value;
		} else {
			costs.addFreeformValue(which, +value);
		}
	}
	return costs;
}

function ParseUnit(root) {
	let unit = new Unit();
	const unitName = ExpandBaseNotes(root, unit);

	let categories = root.querySelectorAll("categories>category");
	for (let cat of categories) {
		const catName = cat.getAttributeNode("name")?.nodeValue;
		if (catName) {
			const factPattern = "Faction: ";
			const factIndex = catName.lastIndexOf(factPattern);
			if (factIndex >= 0) {
				const factKeyword = catName.slice(factIndex + factPattern.length);
				unit.factions.add(factKeyword);
			} else {
				const roleText = catName.trim();
				let unitRole = LookupRole(roleText);
				if (unitRole != UnitRole.NONE) {
					unit.role = unitRole;
				}
				unit.keywords.add(catName);
			}
		}
	}

	const seenProfiles = [];

	// First, find model stats. These have typeName=Unit.
	const modelStatsProfiles = Array.from(
		root.querySelectorAll('profile[typeName="Unit"],profile[typeName="Model"]'),
	);
	ParseModelStatsProfiles(modelStatsProfiles, unit, unitName);
	seenProfiles.push(...modelStatsProfiles);

	// Next, look for selections with models. These usually have type="model",
	// but may have type="upgrade" containing a profile of type="Unit".
	const modelSelections = [];
	if (root.getAttribute("type") === "model") {
		modelSelections.push(root); // Single-model unit.
	} else {
		const immediateSelections = GetImmediateSelections(root);
		for (const selection of immediateSelections) {
			if (
				selection.getAttribute("type") === "model" ||
				HasImmediateProfileWithTypeName(selection, "Unit")
			) {
				modelSelections.push(selection);
			}
		}
		// Some units are under a root selection with type="upgrade".
		if (modelSelections.length === 0) {
			modelSelections.push(
				...Array.from(root.querySelectorAll('selection[type="model"]')),
			);
		}
		// Some single-model units have type="unit" or type="upgrade".
		if (
			modelSelections.length === 0 &&
			HasImmediateProfileWithTypeName(root, "Unit")
		) {
			modelSelections.push(root);
		}
	}

	// Now, parse the model -- profiles for stats, and selections for upgrades.
	for (const modelSelection of modelSelections) {
		const profiles = Array.from(
			modelSelection.querySelectorAll("profiles>profile"),
		);
		const unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e));
		seenProfiles.push(...unseenProfiles);

		const model = new Model();
		model.name = modelSelection.getAttribute("name") || "Unknown Model";
		model.count = Number(modelSelection.getAttribute("number") || 1);
		unit.models.push(model);

		// Find stats for all profiles (weapons, powers, abilities, etc).
		ParseModelProfiles(profiles, model, unit);

		// Find all upgrades on the model. This may include weapons that were
		// parsed from profiles (above), so dedupe those in nameAndGear().
		for (const upgradeSelection of modelSelection.querySelectorAll(
			'selections>selection[type="upgrade"]',
		)) {
			// Ignore selections without abilities but with sub-selection upgrades,
			// since those sub-selections will be picked up individually.
			if (
				upgradeSelection.querySelector(
					'selections>selection[type="upgrade"]',
				) &&
				!HasImmediateProfileWithTypeName(upgradeSelection, "Abilities")
			)
				continue;

			let upgradeName = upgradeSelection.getAttribute("name");
			if (upgradeName) {
				const upgrade = new Upgrade();
				upgrade.name = upgradeName;
				upgrade.cost = GetSelectionCosts(upgradeSelection);
				upgrade.count = Number(upgradeSelection.getAttribute("number"));
				model.upgrades.push(upgrade);
			}
		}
	}

	// Finally, look for profiles that are not under models. They may apply to
	//    a) model loadouts, if it's selection with a Weapon (eg Immortals)
	//    b) unit loadout, if it's a selection with an Ability (eg Bomb Squigs)
	//    c) abilities for the unit, if it's not under a child selection
	let profiles = Array.from(root.querySelectorAll("profiles>profile"));
	let unseenProfiles = profiles.filter((e) => !seenProfiles.includes(e));
	seenProfiles.push(...unseenProfiles);
	if (unseenProfiles.length > 0) {
		const unitUpgradesModel = new Model();
		unitUpgradesModel.name = "Unit Upgrades";
		ParseModelProfiles(unseenProfiles, unitUpgradesModel, unit);
		/* if (unitUpgradesModel.meleeWeapons.length > 0 && unit.models.length > 0) {
			// Apply weapons at the unit level to all models in the unit.
			for (const model of unit.models) {
				model.meleeWeapons.push(...unitUpgradesModel.meleeWeapons);
			}
			unitUpgradesModel.meleeWeapons.length = 0; // Clear the array.
		}
		if (unitUpgradesModel.rangedWeapons.length > 0 && unit.models.length > 0) {
			// Apply weapons at the unit level to all models in the unit.
			for (const model of unit.models) {
				model.rangedWeapons.push(...unitUpgradesModel.rangedWeapons);
			}
			unitUpgradesModel.rangedWeapons.length = 0; // Clear the array.
		} */
		if (unitUpgradesModel.psychicPowers.length > 0) {
			// Add spells to the unit's spell list. However, we'll still need
			// to add spell upgrade selections to the upgrade list, below.
			unit.spells.push(...unitUpgradesModel.psychicPowers);
			unitUpgradesModel.psychicPowers.length = 0;
		}
		if (unitUpgradesModel.psyker) {
			unit.psykers.push(unitUpgradesModel.psyker);
			unitUpgradesModel.psyker = null;
		}
		if (unitUpgradesModel.explosions.length > 0) {
			unit.explosions.push(...unitUpgradesModel.explosions);
			unitUpgradesModel.explosions.length = 0;
		}

		// Look for any unit-level upgrade selections that we didn't catch
		// previously, and stuff them in the "Unit Upgrades" model.
		for (const selection of GetImmediateSelections(root)) {
			if (selection.getAttribute("type") !== "upgrade") continue;
			// Ignore model selections (which were already processed).
			if (modelSelections.includes(selection)) continue;
			// Ignore unit-level weapon selections; these were handled above.
			if (selection.querySelector('profiles>profile[typeName="Weapon"]'))
				continue;

			let name = selection.getAttribute("name");
			if (!name) continue;

			const upgrade = new Upgrade();
			upgrade.name = name;
			upgrade.cost = GetSelectionCosts(selection);
			upgrade.count = Number(selection.getAttribute("number"));
			unitUpgradesModel.upgrades.push(upgrade);
		}

		if (
			unitUpgradesModel.rangedWeapons.length > 0 ||
			unitUpgradesModel.meleeWeapons.length > 0 ||
			unitUpgradesModel.upgrades.length > 0
		) {
			unit.models.push(unitUpgradesModel);
		}
	}

	// Only match costs->costs associated with the unit and not its children (model and weapon) costs.
	let costs = root.querySelectorAll("costs>cost");
	for (let cost of costs) {
		unit.cost.add(ParseCost(cost));
	}

	let rules = root.querySelectorAll("rules > rule");
	for (let rule of rules) {
		if (rule.parentElement.parentElement.getAttribute("type") === "upgrade") {
			continue;
		}
		ExtractRuleDescription(rule, unit.rules);
	}

	unit.normalize();
	return unit;
}

function ParseModelStatsProfiles(profiles, unit, unitName) {
	for (const profile of profiles) {
		const profileName = profile.getAttribute("name");
		const profileType = profile.getAttribute("typeName");
		if (!profileName || !profileType) return;

		const model = new Model();
		model.name = profileName;
		unit.modelStats.push(model);

		ExpandBaseNotes(profile, model);

		const chars = profile.querySelectorAll("characteristics>characteristic");
		for (const char of chars) {
			const charName = char.getAttribute("name");
			if (!charName) continue;

			if (char.textContent) {
				switch (charName) {
					case "M":
						model.move = char.textContent;
						break;
					case "WS":
						model.ws = char.textContent;
						break;
					case "BS":
						model.bs = char.textContent;
						break;
					case "S":
						model.str = +char.textContent;
						break;
					case "T":
						model.toughness = +char.textContent;
						break;
					case "W":
						model.wounds = +char.textContent;
						break;
					case "A":
						model.attacks = char.textContent;
						break;
					case "LD":
						model.leadership = char.textContent;
						break;
					case "SV":
						model.save = char.textContent;
						break;
					case "OC":
						model.oc = char.textContent;
						break;
				}
			}
		}
	}
}

function ParseModelProfiles(profiles, model, unit) {
	for (const profile of profiles) {
		const profileName = profile.getAttribute("name");
		const typeName = profile.getAttribute("typeName");
		if (!profileName || !typeName) continue;

		if (
			typeName === "Unit" ||
			typeName === "Model" ||
			profile.getAttribute("type") === "model"
		) {
			// Do nothing; these were already handled.
		} else if (typeName === "Ranged Weapons") {
			const weapon = ParseWeaponProfile(profile);
			model.rangedWeapons.push(weapon);
		} else if (typeName === "Melee Weapons") {
			const weapon = ParseWeaponProfile(profile);
			model.meleeWeapons.push(weapon);
		} else if (
			typeName.includes("Wound Track") ||
			typeName.includes("Stat Damage") ||
			typeName.includes(" Wounds")
		) {
			const tracker = ParseWoundTrackerProfile(profile);
			unit.woundTracker.push(tracker);
		} else if (typeName == "Psychic Power") {
			const power = ParsePsychicPowerProfile(profile);
			model.psychicPowers.push(power);
		} else if (typeName == "Abilities") {
			if (!unit.abilities.Abilities) unit.abilities["Abilities"] = new Map();
			ParseProfileCharacteristics(
				profile,
				profileName,
				typeName,
				unit.abilities.Abilities,
			);
		} else {
			// Everything else, like Prayers and Warlord Traits.
			if (!unit.abilities[typeName]) unit.abilities[typeName] = new Map();
			ParseProfileCharacteristics(
				profile,
				profileName,
				typeName,
				unit.abilities[typeName],
			);
		}
	}
}

function ParseProfileCharacteristics(profile, profileName, typeName, map) {
	const chars = profile.querySelectorAll("characteristics>characteristic");
	for (const char of chars) {
		if (!char.textContent) continue;
		const charName = char.getAttribute("name");
		if (charName && chars.length > 1) {
			// Profiles with multiple characteristics need to distinguish them by name.
			map.set([profileName, charName.toString()].join(" - "), char.textContent);
		} else {
			// Profiles with a single characteristic can ignore the char name.
			if (!map) {
				debugger;
			}
			map.set(profileName, char.textContent);
		}
	}
}

function ParseWeaponProfile(profile) {
	const weapon = new Weapon();
	ExpandBaseNotes(profile, weapon);
	weapon.count = ExtractNumberFromParent(profile);

	let chars = profile.querySelectorAll("characteristics>characteristic");
	for (let char of chars) {
		let charName = char.getAttribute("name");
		if (charName) {
			if (char.textContent) {
				switch (charName) {
					case "Range":
						weapon.range = char.textContent;
						break;
					case "Type":
						weapon.type = char.textContent;
						break;
					case "S":
						weapon.str = char.textContent;
						break;
					case "AP":
						weapon.ap = char.textContent;
						break;
					case "D":
						weapon.damage = char.textContent;
						break;
					case "Abilities":
						weapon.abilities = char.textContent;
						break;
					case "BS":
						weapon.bs = char.textContent;
						break;
					case "WS":
						weapon.ws = char.textContent;
						break;
					case "A":
						weapon.attacks = char.textContent;
						break;
					case "Keywords":
						weapon.type = char.textContent;
						break;
				}
			}
		}
	}
	// Keep track of the weapon's parent selection for its name, unless the
	// weapon is directly under the unit's profile.
	const selection = profile.parentElement?.parentElement;
	const selectionName = selection?.getAttribute("name");
	if (selection?.getAttribute("type") === "upgrade" && selectionName) {
		weapon.selectionName = selectionName;
		weapon.cost = GetSelectionCosts(selection);
	}
	return weapon;
}

function ParseWoundTrackerProfile(profile) {
	let tracker = new WoundTracker();
	ExpandBaseNotes(profile, tracker);
	let chars = profile.querySelectorAll("characteristics>characteristic");
	for (let char of chars) {
		const charName = char.getAttribute("name");
		if (charName) {
			if (char.textContent) {
				tracker.table.set(charName, char.textContent);
			} else {
				tracker.table.set(charName, "-");
			}
		}
	}
	return tracker;
}

function ParsePsychicPowerProfile(profile) {
	const power = new PsychicPower();
	ExpandBaseNotes(profile, power);

	const chars = profile.querySelectorAll("characteristics>characteristic");
	for (let char of chars) {
		const charName = char.getAttribute("name");
		if (charName && char.textContent) {
			switch (charName) {
				case "Range":
					power.range = char.textContent;
					break;
				case "Warp Charge":
					power.manifest = +char.textContent;
					break;
				case "Details":
					power.details = char.textContent;
					break;
			}
		}
	}
	return power;
}

function ParseExplosionProfile(profile) {
	const explosion = new Explosion();
	ExpandBaseNotes(profile, explosion);

	const chars = profile.querySelectorAll("characteristics>characteristic");
	for (const char of chars) {
		const charName = char.getAttribute("name");
		if (charName && char.textContent) {
			switch (charName) {
				case "Dice Roll":
					explosion.diceRoll = char.textContent;
					break;
				case "Distance":
					explosion.distance = char.textContent;
					break;
				case "Mortal Wounds":
					explosion.mortalWounds = char.textContent;
					break;
			}
		}
	}
	return explosion;
}

function ParsePsykerProfile(profile) {
	const psyker = new Psyker();
	ExpandBaseNotes(profile, psyker);

	const chars = profile.querySelectorAll("characteristics>characteristic");
	for (const char of chars) {
		const charName = char.getAttribute("name");
		if (charName && char.textContent) {
			switch (charName) {
				case "Cast":
					psyker.cast = char.textContent;
					break;
				case "Deny":
					psyker.deny = char.textContent;
					break;
				case "Powers Known":
					psyker.powers = char.textContent;
					break;
				case "Other":
					psyker.other = char.textContent;
					break;
			}
		}
	}
	return psyker;
}

function CompareObj(a, b) {
	return Compare(a.name, b.name);
}

function CompareModel(a, b) {
	if (a.name === b.name) {
		return Compare(a.nameAndGear(), b.nameAndGear());
	} else if (a.name === "Unit Upgrades") {
		// "Unit Upgrades", a special model name, is always sorted last.
		return 1;
	} else if (b.name === "Unit Upgrades") {
		// "Unit Upgrades", a special model name, is always sorted last.
		return -1;
	} else {
		return Compare(a.name, b.name);
	}
}

export function CompareWeapon(a, b) {
	const aType = a.type.startsWith("Grenade")
		? 2
		: a.type.startsWith("Melee")
		? 1
		: 0;
	const bType = b.type.startsWith("Grenade")
		? 2
		: b.type.startsWith("Melee")
		? 1
		: 0;
	return aType - bType || a.name.localeCompare(b.name);
}

export function Compare(a, b) {
	if (a > b) return 1;
	else if (a == b) return 0;
	return -1;
}
