// Consolidate duplicate units into a single unit.  Ported from
// python/merge_duplicate_units.py.
//
// Mutually exclusive wargear forces you to take the same datasheet twice: one
// Sydonian Skatros with a radium jezzail, another with a transuranic arquebus;
// one squad of Skitarii Rangers with an omnispex, another with a data-tether.
// Printing that gives you two near-identical cards that differ in one row.
//
// This folds those copies together.  Duplicate units - same ``entryId``, same
// name, same type, in the same force - are merged into the first copy, and every
// selection the other copies contribute that the first one lacks is grafted on.
// The merge is recursive, so it works at whatever depth the models differ:
//
//   * Skatros - the copies are single models, so the extra *weapon* selection is
//     what gets added.
//   * Rangers - the copies differ by a whole model (``Skitarii Ranger w/
//     data-tether``), which is added with its weapons *and* its ``Enhanced
//     data-tether`` ability underneath it, because the ability hangs off the
//     model that brings it.  Nothing special is needed for the ability: grafting
//     the model on carries its whole subtree along.
//
// Profiles, rules and categories are merged the same way, so an option that
// contributes a unit-level ability or keyword brings it with it.
//
// Points are *not* summed.  The consolidated unit keeps the highest cost of the
// copies it absorbed, since the card now shows the most expensive loadout, and
// the roster total in ``<roster><costs>`` is left alone - it still reports what
// the army actually costs.

import { CONTAINER_ORDER } from "./config.js";
import {
	KEY_SEPARATOR,
	childElement,
	descendants,
	elementChildren,
	insertOrdered,
	removeElement,
} from "./dom.js";

// Attribute a merged pair keeps the larger of.  Two copies of a squad can list
// different model counts for the same entry; the consolidated card shows the
// largest, the same way it shows the largest cost.
const COUNT_ATTR = "number";

/**
 * Identity of a child element within its container.
 *
 * Selections are keyed on ``entryId`` - the catalogue entry plus option path -
 * because their ``id`` is per-instance and differs between two copies of the
 * same squad.  Profiles, rules and categories are shared catalogue objects, so
 * their ``id`` matches across copies.  Costs are keyed by name/typeId alone.
 * Anything missing its key falls back to the name, which is all a renderer would
 * tell apart anyway.
 */
function childKey(node) {
	let key = null;
	if (node.localName === "selection") {
		key = node.getAttribute("entryId");
	} else if (node.localName !== "cost") {
		key = node.getAttribute("id");
	}
	return [
		node.localName,
		key,
		node.getAttribute("name"),
		node.getAttribute("typeId"),
		node.getAttribute("typeName"),
	].join(KEY_SEPARATOR);
}

const describe = (node) => {
	const name = node.getAttribute("name") ?? `<${node.localName}>`;
	const typeName = node.getAttribute("typeName");
	return typeName ? `${name} (${typeName})` : name;
};

const isConfiguration = (unit, skipCategories) => {
	const categories = childElement(unit, "categories");
	if (!categories) return false;
	return elementChildren(categories).some((category) =>
		skipCategories.has(category.getAttribute("name")),
	);
};

const unitKey = (unit, matchAttrs) =>
	matchAttrs.map((attr) => unit.getAttribute(attr)).join(KEY_SEPARATOR);

/** Keep the larger of two numeric attributes on the unit we are keeping. */
function raiseNumericAttr(target, donor, attr) {
	if (!target.hasAttribute(attr)) return;
	const keep = Number(target.getAttribute(attr));
	const other = donor.hasAttribute(attr)
		? Number(donor.getAttribute(attr))
		: keep;
	if (Number.isNaN(keep) || Number.isNaN(other)) return;
	if (other > keep) target.setAttribute(attr, donor.getAttribute(attr));
}

/**
 * Log what the merge contributed, for the run report.
 *
 * Costs are skipped: a points line is not an option the reader was missing.
 */
function noteAdded(added, path, container, nodes) {
	if (container === "costs") return;
	for (const node of nodes) added.push([...path, describe(node)].join(" > "));
}

/** Fold everything `donor` has and `target` lacks into `target`. */
function mergeSelection(target, donor, path, added) {
	raiseNumericAttr(target, donor, COUNT_ATTR);

	for (const container of CONTAINER_ORDER) {
		const donorContainer = childElement(donor, container);
		if (!donorContainer || donorContainer.children.length === 0) continue;

		const targetContainer = childElement(target, container);
		if (!targetContainer) {
			// The whole container is new - graft it on, keeping CONTAINER_ORDER.
			insertOrdered(target, donorContainer.cloneNode(true), CONTAINER_ORDER);
			noteAdded(added, path, container, elementChildren(donorContainer));
			continue;
		}

		// Pair each donor child with an unclaimed target child of the same
		// identity; a container may legitimately hold two entries with the same
		// key, so matches are consumed rather than looked up.
		const unclaimed = elementChildren(targetContainer);
		const additions = [];
		for (const donorChild of elementChildren(donorContainer)) {
			const key = childKey(donorChild);
			const index = unclaimed.findIndex((child) => childKey(child) === key);
			if (index === -1) {
				additions.push(donorChild);
				continue;
			}
			const [match] = unclaimed.splice(index, 1);
			if (container === "selections") {
				mergeSelection(match, donorChild, [...path, describe(match)], added);
			} else if (container === "costs") {
				// Same cost type on both copies: show the pricier loadout.
				raiseNumericAttr(match, donorChild, "value");
			}
		}

		if (additions.length > 0) {
			for (const addition of additions) {
				targetContainer.appendChild(addition.cloneNode(true));
			}
			noteAdded(added, path, container, additions);
		}
	}
}

/**
 * Merge the duplicate units in `doc`, in place.
 *
 * Returns a report of `[{ unit, copies, additions }]`, where `copies` counts the
 * copies the consolidated unit now stands for (2 means one was absorbed).  An
 * empty report means there were no duplicates.
 *
 * Unlike the Python original this merges every duplicate in a single pass: that
 * script re-parses the file after each merge because applying a text edit
 * invalidates the offsets it works from, which a DOM does not have.
 */
export function mergeDuplicateUnits(doc, config) {
	const root = doc.documentElement;
	if (!root) return [];

	const skipCategories = new Set(config.skipCategories);
	const matchAttrs = config.unitMatchAttrs;
	const report = [];

	for (const force of descendants(root, "force")) {
		const selections = childElement(force, "selections");
		if (!selections) continue;

		const groups = new Map();
		for (const unit of elementChildren(selections)) {
			if (
				unit.localName !== "selection" ||
				isConfiguration(unit, skipCategories)
			) {
				continue;
			}
			const key = unitKey(unit, matchAttrs);
			groups.set(key, [...(groups.get(key) ?? []), unit]);
		}

		for (const units of groups.values()) {
			if (units.length < 2) continue;

			const [target, ...donors] = units;
			const additions = [];
			for (const donor of donors) {
				mergeSelection(target, donor, [], additions);
				removeElement(donor);
			}
			report.push({
				unit: target.getAttribute("name") ?? "?",
				copies: units.length,
				additions,
			});
		}
	}

	return report;
}
