// Split "choose one each turn" abilities into per-option profiles.  Ported from
// convert_choice_abilities.py; see README.md on the fixtures.
//
// Abilities such as Belisarius Cawl's *Canticles of the Omnissiah* arrive from
// the catalogue as a single ``Abilities`` profile whose Description crams the
// intro sentence and every selectable option into one blob of text.  Official
// datasheets instead print the options as their own titled block in the weapons
// column, one row per option.
//
// This rewrites the roster to match:
//
//   * the original ``Abilities`` profile is kept, trimmed to the intro
//     paragraph (the "select one of the following" rule), and
//   * each option becomes its own profile under a new profile type named after
//     the ability, so the renderer groups them into a single titled table.
//
// Running it twice is a no-op: a converted ability has only an intro paragraph
// left, so there is nothing further to split.

import { ABILITY_TYPE_NAME, DESCRIPTION_CHARACTERISTIC } from "./config.js";
import { childElement, createElement, descendants, makeId } from "./dom.js";

// Whitespace that shows up in these files, including the non-breaking spaces
// that GW's text is littered with.
const WS = " \t  ";

// An option paragraph looks like "Some Name: rules text...".  A label longer
// than this is assumed to be prose that merely contains a colon, not an option.
const MAX_OPTION_NAME_LEN = 60;

// Some abilities list their options as a bullet list inside a single paragraph
// instead of one paragraph per option (Battle Protocols does, Canticles of the
// Omnissiah does not).  A line opening with one of these markers followed by a
// "Label:" starts an option there too.
const BULLET_CHARS = "-–—•■";

const PARAGRAPH_SPLIT_RE = new RegExp(`\n[${WS}]*\n`);
const OPTION_RE = new RegExp(
	`^([^\n:]{1,${MAX_OPTION_NAME_LEN}}):[${WS}]*(.+)$`,
	"s",
);
const BULLET_OPTION_RE = new RegExp(
	`^[${WS}]*[${BULLET_CHARS}][${WS}]+[^\n:]{1,${MAX_OPTION_NAME_LEN}}:`,
	"gm",
);

// ``^^Keyword^^``, tolerating the ``**`` GW likes to nest inside (or across)
// the carets; ``[^^]`` keeps a match from swallowing the next keyword.
const KEYWORD_RE = /\^\^([^^]+?)\^\^/g;
const LEFTOVER_MARKER_RE = /\*\*|\^\^/g;

/** Python's `str.strip(chars)`: trim any of `chars` from both ends. */
function trimChars(text, chars) {
	let start = 0;
	let end = text.length;
	while (start < end && chars.includes(text[start])) start++;
	while (end > start && chars.includes(text[end - 1])) end--;
	return text.slice(start, end);
}

/** Python's `str.lstrip(chars)`. */
function trimStartChars(text, chars) {
	let start = 0;
	while (start < text.length && chars.includes(text[start])) start++;
	return text.slice(start);
}

/** Trim whitespace and GW's markdown/keyword emphasis from an option label. */
const cleanOptionName = (name) =>
	trimChars(trimChars(trimChars(name, `${WS}\n`), "*^"), WS);

/**
 * Resolve GW's ^^keyword^^ / **emphasis** markers into plain text.
 *
 * Keywords are upper-cased, emphasis is simply dropped, and any marker left
 * over from mis-nested pairs goes too - a stray ``**`` is punctuation the
 * reader never wanted to see either way.
 *
 * The generated group renders through the renderer's generic ability table,
 * which prints its text verbatim, so the markers would otherwise show up as
 * literal punctuation.  The intro profile keeps its original typeName, renders
 * through the normal path, and is therefore left alone.
 *
 * Unlike the Python original this needs no entity bookkeeping: the DOM hands us
 * resolved text, so there is no ``&quot;`` here that upper-casing could turn
 * into an entity no parser would recognise.
 */
const stripMarkup = (text) =>
	text
		.replace(KEYWORD_RE, (_, keyword) =>
			keyword.replaceAll("*", "").toUpperCase(),
		)
		.replace(LEFTOVER_MARKER_RE, "");

/**
 * Cut a paragraph before each bulleted ``- Label:`` line, dropping the bullet.
 *
 * Anything ahead of the first such line stays as one block, and a bulleted
 * option runs until the next one, so multi-line option text stays together.
 * Paragraphs without bulleted options come back unchanged.
 */
function splitBullets(paragraph) {
	const starts = Array.from(
		paragraph.matchAll(BULLET_OPTION_RE),
		(m) => m.index,
	);
	if (starts.length === 0) return [paragraph];

	const bounds = [0, ...starts];
	const ends = [...starts, paragraph.length];
	return bounds.map((start, index) => {
		const block = paragraph.slice(start, ends[index]);
		// Everything but the lead-in opens with a bullet marker.  Keyed on the
		// index rather than the offset, so a paragraph whose very first
		// character is a bullet still gets it stripped.
		if (index === 0) return block;
		return trimStartChars(
			trimStartChars(trimStartChars(block, `${WS}\n`), BULLET_CHARS),
			WS,
		);
	});
}

/**
 * Split an ability description into `{ intro, options }`.
 *
 * Paragraphs are separated by blank lines, or by bulleted option lines within a
 * paragraph.  The first paragraph is the intro.  Each later paragraph starting
 * ``Label: text`` opens a new option; anything else is treated as a
 * continuation of whatever came before.
 */
export function splitDescription(text) {
	const paragraphs = text
		.split(PARAGRAPH_SPLIT_RE)
		.flatMap(splitBullets)
		.filter((block) => trimChars(block, `${WS}\n`) !== "");

	const introParts = [];
	const options = [];

	for (const paragraph of paragraphs) {
		const match = OPTION_RE.exec(trimChars(paragraph, "\n"));
		if (match) {
			const name = cleanOptionName(match[1]);
			if (name) {
				options.push({ name, parts: [trimChars(match[2], `${WS}\n`)] });
				continue;
			}
		}
		// Not an option header: continuation of the previous block.
		if (options.length > 0) {
			options.at(-1).parts.push(trimChars(paragraph, "\n"));
		} else {
			introParts.push(trimChars(paragraph, "\n"));
		}
	}

	return {
		intro: trimChars(introParts.join("\n\n"), `${WS}\n`),
		options: options.map(({ name, parts }) => ({
			name,
			text: parts.join("\n\n"),
		})),
	};
}

/** Build one `<profile>` with a single Description characteristic. */
function buildProfile(
	doc,
	template,
	{ id, name, typeId, typeName, charTypeId, description },
) {
	const profile = createElement(doc, "profile");
	profile.setAttribute("id", id);
	profile.setAttribute("name", name);
	profile.setAttribute("hidden", "false");
	profile.setAttribute("typeId", typeId);
	profile.setAttribute("typeName", typeName);

	// Preserve attributes we do not manage ourselves (page, publicationId, ...).
	const managed = new Set(["id", "name", "hidden", "typeId", "typeName"]);
	for (const attr of Array.from(template.attributes)) {
		if (!managed.has(attr.name)) profile.setAttribute(attr.name, attr.value);
	}

	const characteristics = createElement(doc, "characteristics");
	const characteristic = createElement(doc, "characteristic");
	characteristic.setAttribute("name", DESCRIPTION_CHARACTERISTIC);
	characteristic.setAttribute("typeId", charTypeId);
	characteristic.textContent = description;
	characteristics.appendChild(characteristic);
	profile.appendChild(characteristics);
	return profile;
}

/**
 * Split the configured choice abilities in `doc`, in place.
 *
 * Returns a report of `[{ ability, options: [names] }]`; an empty report means
 * nothing matched (already converted, or no such ability in the roster).
 */
export function convertChoiceAbilities(doc, config) {
	const wanted = new Set(config.abilitiesToConvert);
	const overrides = config.groupTitleOverrides ?? {};
	const root = doc.documentElement;
	if (!root) return [];

	const report = [];

	for (const profile of descendants(root, "profile")) {
		const ability = profile.getAttribute("name");
		if (
			profile.getAttribute("typeName") !== ABILITY_TYPE_NAME ||
			!wanted.has(ability)
		) {
			continue;
		}

		const characteristics = childElement(profile, "characteristics");
		const characteristic = characteristics
			? childElement(characteristics, "characteristic")
			: null;
		if (
			!characteristic ||
			characteristic.getAttribute("name") !== DESCRIPTION_CHARACTERISTIC
		) {
			// Not the shape this transform knows how to split; leave it be rather
			// than throw, so one odd profile cannot stop the roster rendering.
			continue;
		}

		const { intro, options } = splitDescription(
			characteristic.textContent ?? "",
		);
		if (options.length === 0) continue; // Already converted, or a plain ability.

		const groupTitle = overrides[ability] ?? ability;
		const typeId = makeId("profileType", groupTitle);
		const charTypeId = makeId(
			"characteristicType",
			groupTitle,
			DESCRIPTION_CHARACTERISTIC,
		);

		const replacements = [];
		if (intro) {
			// Keep the original ability profile - and its id - as the pointer text.
			replacements.push(
				buildProfile(doc, profile, {
					id: profile.getAttribute("id"),
					name: ability,
					typeId: profile.getAttribute("typeId"),
					typeName: profile.getAttribute("typeName"),
					charTypeId: characteristic.getAttribute("typeId"),
					description: intro,
				}),
			);
		}

		for (const option of options) {
			replacements.push(
				buildProfile(doc, profile, {
					id: makeId("profile", profile.getAttribute("id"), option.name),
					name: stripMarkup(option.name),
					typeId,
					typeName: groupTitle,
					charTypeId,
					description: stripMarkup(option.text),
				}),
			);
		}

		profile.replaceWith(...replacements);
		report.push({ ability, options: options.map((option) => option.name) });
	}

	return report;
}
