// Parity tests: the transforms must produce the same roster as the Python
// scripts they were ported from, which are the version that had actually been
// used to print cards.
//
// The `.merge`/`.strip`/`.convert`/`.all` fixtures are that Python output,
// checked in - so this suite compares the code against an implementation that
// is genuinely independent of it, without needing a Python interpreter.  See
// README.md and scripts/update-fixtures.mjs before regenerating any of them.
//
// Comparison is structural rather than textual.  The Python scripts edit the
// file as text and preserve its byte-for-byte formatting; these transforms build
// DOM nodes, so attribute order and whitespace legitimately differ.  What has to
// match is the tree: elements, nesting, attribute values, and text.

import { readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

import { defaultConfig } from "./config.js";
import {
	convertChoiceAbilities,
	splitDescription,
	stripMarkup,
} from "./convertChoiceAbilities.js";
import { applyTransforms } from "./index.js";
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
import { removeLeaderAbilities } from "./removeLeaderAbilities.js";

const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");

const examples = readdirSync(FIXTURES)
	.filter((name) => name.endsWith(".ros") && name.split(".").length === 2)
	.map((name) => name.replace(/\.ros$/, ""));

const parse = (xml) => {
	const doc = new DOMParser().parseFromString(xml, "text/xml");
	const failure = doc.querySelector("parsererror");
	if (failure) throw new Error(`fixture did not parse: ${failure.textContent}`);
	return doc;
};

const load = (name) =>
	parse(readFileSync(join(FIXTURES, `${name}.ros`), "utf8"));

/**
 * A stable text rendering of a document's tree, for comparison.
 *
 * `id` and `typeId` are replaced with a token per distinct value, in
 * first-appearance order: the splitter mints ids for the profiles it generates,
 * and the JS port hashes them differently from the Python original.  What
 * matters is that ids are shared and distinct in the same *pattern*, which the
 * tokens preserve, not that the two implementations agree on a hash function.
 * Every other attribute - including `entryId` and `publicationId`, which are
 * never generated - has to match exactly.
 *
 * Text goes through `stripMarkup` for the same reason.  Python resolved GW's
 * ^^keyword^^ / **emphasis** markers in the option text it split out, because
 * the renderer printed that text verbatim; the renderer now formats the markers
 * itself, so the transform leaves them in place.  Resolving them on both sides
 * keeps the fixtures an oracle for everything else about the split.
 */
function canonicalize(doc) {
	const tokens = new Map();
	const token = (value) => {
		if (!tokens.has(value)) tokens.set(value, `#${tokens.size}`);
		return tokens.get(value);
	};
	const generated = new Set(["id", "typeId"]);
	const lines = [];

	const walk = (element, depth) => {
		const attributes = Array.from(element.attributes)
			.map((attr) => [
				attr.name,
				generated.has(attr.name) ? token(attr.value) : attr.value,
			])
			.sort(([a], [b]) => (a < b ? -1 : 1))
			.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
			.join(" ");

		const text = stripMarkup(
			Array.from(element.childNodes)
				.filter((node) => node.nodeType === 3 /* Text */)
				.map((node) => node.data)
				.join("")
				.trim(),
		);

		lines.push(
			`${"  ".repeat(depth)}<${element.localName} ${attributes}>` +
				(text ? ` ${JSON.stringify(text)}` : ""),
		);
		for (const child of element.children) walk(child, depth + 1);
	};

	walk(doc.documentElement, 0);
	return lines.join("\n");
}

const cases = [
	["merge", (doc) => mergeDuplicateUnits(doc, defaultConfig)],
	["strip", (doc) => removeLeaderAbilities(doc, defaultConfig)],
	["convert", (doc) => convertChoiceAbilities(doc, defaultConfig)],
	["all", (doc) => applyTransforms(doc)],
];

describe.each(cases)("%s", (label, run) => {
	it.each(examples)("matches the python output for %s", (example) => {
		const doc = load(example);
		run(doc);
		expect(canonicalize(doc)).toBe(canonicalize(load(`${example}.${label}`)));
	});

	it.each(examples)("is a no-op on second run for %s", (example) => {
		const doc = load(example);
		run(doc);
		const once = canonicalize(doc);
		run(doc);
		expect(canonicalize(doc)).toBe(once);
	});
});

describe("splitDescription", () => {
	it("keeps a plain ability whole", () => {
		const { intro, options } = splitDescription(
			"While this model is leading a unit, add 1 to the Objective Control characteristic of models in that unit.",
		);
		expect(options).toEqual([]);
		expect(intro).toMatch(/^While this model/);
	});

	it("splits blank-line separated options", () => {
		const { intro, options } = splitDescription(
			"At the start of your Command phase, select one:\n\n" +
				"Shroudpsalm: Models in this army have the Stealth ability.\n\n" +
				"Chant of the Remorseless Fist: Melee weapons have the [LANCE] ability.",
		);
		expect(intro).toBe("At the start of your Command phase, select one:");
		expect(options.map((option) => option.name)).toEqual([
			"Shroudpsalm",
			"Chant of the Remorseless Fist",
		]);
		expect(options[0].text).toBe(
			"Models in this army have the Stealth ability.",
		);
	});

	it("splits a bulleted list inside one paragraph", () => {
		const { intro, options } = splitDescription(
			"Select one protocol:\n" +
				"- Protocol of the Eternal Guardian: Models have Feel No Pain 6+.\n" +
				"- Protocol of the Sudden Storm: Models have the Assault ability.",
		);
		expect(intro).toBe("Select one protocol:");
		expect(options.map((option) => option.name)).toEqual([
			"Protocol of the Eternal Guardian",
			"Protocol of the Sudden Storm",
		]);
	});

	it("treats a long prose colon as prose, not an option", () => {
		const sentence = `${"a".repeat(70)}: still the same paragraph.`;
		const { options } = splitDescription(`Intro paragraph.\n\n${sentence}`);
		expect(options).toEqual([]);
	});
});
