// Lookup of the flavour text that official datasheets print to the right of the
// model image.  The data lives in `public/Lore.csv`, a pipe-delimited export of
// `name|legend` (plus an optional `faction` column, see `buildLoreIndex`).
//
// Nothing about roster names is reliable enough for an exact lookup:  the same
// unit is spelled "Tech-priest Dominus" in one place and "Tech-Priest Dominus"
// in another, a roster may name a unit in the plural where the export uses the
// singular ("Myphitic Blight-haulers" / "Myphitic Blight-hauler"), and faction
// catalogues prefix names that the export does not ("Thousand Sons Chaos
// Spawn" / "Chaos Spawn").  So the index is keyed by a normalised form and
// consulted through three widening attempts.

/**
 * Case, accents, curly quotes and punctuation all vary between the export and
 * the roster, and none of them carry meaning here, so collapse the lot.
 */
export const normalizeName = (name) =>
	String(name ?? "")
		.normalize("NFKD")
		.replace(/\p{M}/gu, "")
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, " ")
		.trim();

/**
 * Crude singularisation of the last word, which is the only place a roster and
 * the export tend to disagree on number.  Deliberately not a real stemmer: it
 * runs over both sides of the comparison, so it only has to be consistent, not
 * correct.
 */
const singularize = (word) => {
	if (word.length < 4) return word;
	if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
	if (/(?:ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2);
	if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
	return word;
};

const stemKey = (normalized) => {
	const words = normalized.split(" ");
	if (!words.length) return normalized;
	words[words.length - 1] = singularize(words[words.length - 1]);
	return words.join(" ");
};

const splitLines = (text) =>
	String(text ?? "")
		.replace(/^\ufeff/, "")
		.split(/\r?\n/);

/**
 * Parses the export into `{ name, legend, faction }` rows.  Rows without a
 * legend are dropped - the export carries a few hundred of them, one per unit
 * whose lore has not been transcribed yet, and they would otherwise shadow a
 * usable entry for the same name.
 *
 * Columns are located by the header line, so adding a `faction` column (see
 * `pickEntry`) does not need a code change.
 */
export const parseLore = (text) => {
	const lines = splitLines(text).filter((line) => line.trim());
	if (!lines.length) return [];

	const header = lines[0].split("|").map((h) => h.trim().toLowerCase());
	const nameCol = header.indexOf("name");
	const legendCol = header.indexOf("legend");
	const factionCol = header.indexOf("faction");
	if (nameCol === -1 || legendCol === -1) return [];

	const rows = [];
	for (const line of lines.slice(1)) {
		const fields = line.split("|");
		const name = fields[nameCol]?.trim();
		const legend = fields[legendCol]?.trim();
		if (!name || !legend) continue;
		rows.push({
			name,
			legend,
			faction: factionCol === -1 ? "" : (fields[factionCol]?.trim() ?? ""),
		});
	}
	return rows;
};

/**
 * Several names carry more than one legend - either the same unit reworded
 * between editions, or a genuinely different unit sharing a name across
 * factions (Chaos Daemons and Death Guard both field Plaguebearers; three
 * different armies field a Ministorum Priest).
 *
 * Given a `faction` column in the export, that ambiguity is resolvable and we
 * prefer the entry whose faction matches the card.  Without one - which is the
 * case for today's export - fall back to the longest legend.  That is not
 * always the *right* variant, but it is deterministic, and the competing
 * variants are near-identical rewrites in all but a handful of cases.
 */
const pickEntry = (entries, faction) => {
	const wanted = normalizeName(faction);
	if (wanted) {
		const match = entries.find((entry) => {
			const entryFaction = normalizeName(entry.faction);
			return (
				entryFaction &&
				(entryFaction === wanted ||
					wanted.includes(entryFaction) ||
					entryFaction.includes(wanted))
			);
		});
		if (match) return match;
	}
	return entries.reduce((best, entry) =>
		entry.legend.length > best.legend.length ? entry : best,
	);
};

/**
 * Builds the lookup.  `lookup(name, faction)` returns the legend string, or
 * `undefined` when the unit has no entry.
 */
export const buildLoreIndex = (text) => {
	const exact = new Map();
	const stems = new Map();

	for (const row of parseLore(text)) {
		const key = normalizeName(row.name);
		if (!key) continue;
		if (!exact.has(key)) exact.set(key, []);
		exact.get(key).push(row);

		const stem = stemKey(key);
		if (!stems.has(stem)) stems.set(stem, []);
		stems.get(stem).push(row);
	}

	// Multi-word keys, longest first, for the trailing-name pass below.  Single
	// word keys are excluded: "Guard" or "Rangers" would match half the export.
	const suffixKeys = [...stems.keys()]
		.filter((key) => key.includes(" "))
		.sort((a, b) => b.length - a.length);

	const lookup = (name, faction) => {
		const normalized = normalizeName(name);
		if (!normalized) return undefined;

		const entries =
			exact.get(normalized) ??
			stems.get(stemKey(normalized)) ??
			// Last resort: the roster name ends with a name we know, which is how
			// faction-prefixed datasheets ("Thousand Sons Chaos Spawn") arrive.
			stems.get(
				suffixKeys.find((key) => stemKey(normalized).endsWith(` ${key}`)),
			);

		return entries ? pickEntry(entries, faction).legend : undefined;
	};

	return { lookup, size: exact.size };
};
