// Validate Wiki
// Deterministic checker for a wiki built as a group of interlinked Markdown
// documents - the post-build gate of the /wiki chat skill, equally usable as a
// smart rule's Script Output action or in batch processing.
//
// scriptoutput(theRecord, theInput)
//   theRecord : the wiki's group
//   theInput  : optional - the home page's name, when it differs from the
//               conventions (the group's name, "Index", "Home", "Overview")
//
// The runtime is not DEVONthink's work but the Apple event round trip - every
// property costs the same, whatever it computes - so the properties of a whole
// group's children are asked for at once rather than one record at a time: a
// group of 25 pages costs nine events instead of 129.  A group whose batch fails
// falls back to reading its children one at a time, so a single unreadable child
// costs its own properties instead of the whole run.
//
// The handler is spelled all lowercase on purpose: handlers are invoked by their
// lowercased name, and JavaScript function lookup - unlike AppleScript's - is
// case-sensitive.
//
// Checks: a home page exists; no unlinked Wiki links; every page is reachable
// from the home page; no duplicate page names; no stub pages.
// Returns "PASS - -" or "FAIL - -" with one line per finding.
//
// Installed as "Validate Wiki.js" in
// ~/Library/Application Scripts/com.devon-technologies.think/Skills/

const pStubLength = 100; // pages with fewer characters count as stubs

function scriptoutput(theRecord, theInput) {
	try {
		if (theRecord == null)
			return "ERROR: Pass the wiki's group as the record.";
		if (theRecord.recordType() !== "group")
			return "ERROR: '" + theRecord.name() + "' is not a group.";
		const groupName = theRecord.name();

		// per page: {name, uuid, aliases, characterCount, unlinked, wikiRefs, itemRefs}
		const thePages = [];
		collectPages(theRecord, thePages, new Set());
		const totalPages = thePages.length;
		if (totalPages === 0)
			return "FAIL: '" + groupName + "' contains no Markdown documents.";

		const findings = [];
		const outLinks = []; // per page: uuids of outgoing references, both kinds
		const pageIndexByKey = new Map(); // lowercased name/alias -> first page index
		const pageIndexByUUID = new Map();
		let wikiRefTotal = 0;
		let itemRefTotal = 0;

		for (let i = 0; i < totalPages; i++) {
			const page = thePages[i];
			const theName = page.name;

			// lowercased keys keep the lookups case-insensitive like Wiki
			// linking itself; first occurrence wins, so wiki links resolve
			// to the earliest page of an ambiguous name
			const nameKey = theName.toLowerCase();
			if (pageIndexByKey.has(nameKey))
				findings.push("Duplicate page name '" + theName + "' - wiki links to it are ambiguous.");
			else
				pageIndexByKey.set(nameKey, i);
			if (!pageIndexByUUID.has(page.uuid))
				pageIndexByUUID.set(page.uuid, i);

			for (const theAlias of splitList(page.aliases)) {
				const aliasKey = theAlias.toLowerCase();
				if (!pageIndexByKey.has(aliasKey))
					pageIndexByKey.set(aliasKey, i);
			}

			if (page.characterCount < pStubLength)
				findings.push("Stub page '" + theName + "' (" + page.characterCount + " characters).");

			for (const wikiLink of page.unlinked)
				findings.push("'" + theName + "': wiki link [[" + wikiLink + "]] does not resolve.");

			wikiRefTotal += page.wikiRefs.length;
			itemRefTotal += page.itemRefs.length;
			outLinks.push(page.wikiRefs.concat(page.itemRefs));
		}

		// home page
		const homeCandidates = [];
		if (theInput != null && String(theInput) !== "")
			homeCandidates.push(String(theInput));
		homeCandidates.push(groupName, "Index", "Home", "Overview");
		let homeIndex = -1;
		for (const candidate of homeCandidates) {
			const found = pageIndexByKey.get(candidate.toLowerCase());
			if (found !== undefined) {
				homeIndex = found;
				break;
			}
		}
		if (homeIndex < 0)
			findings.push("No home page: no page is named '" + groupName + "', 'Index', 'Home' or 'Overview'.");

		// reachability from the home page (breadth-first over both link kinds)
		if (homeIndex >= 0) {
			const visited = new Array(totalPages).fill(false);
			visited[homeIndex] = true;
			const theQueue = [homeIndex];
			for (let next = 0; next < theQueue.length; next++)
				for (const theUUID of outLinks[theQueue[next]]) {
					const j = pageIndexByUUID.get(theUUID);
					if (j !== undefined && !visited[j]) {
						visited[j] = true;
						theQueue.push(j);
					}
				}
			for (let i = 0; i < totalPages; i++)
				if (!visited[i])
					findings.push("Orphan page '" + thePages[i].name + "' - not reachable from '" + thePages[homeIndex].name + "'.");
		}

		const summaryText = totalPages + " page(s), " + wikiRefTotal + " Wiki reference(s), " + itemRefTotal + " item reference(s)";
		if (findings.length === 0)
			return "PASS - " + summaryText + "; no unlinked Wiki links, every page reachable from '" + thePages[homeIndex].name + "'.";
		return "FAIL - " + findings.length + " issue(s), " + summaryText + ":\n- " + findings.join("\n- ");
	}
	catch (e) {
		return "ERROR: " + (e.message || String(e)) + " (" + (e.errorNumber || 0) + ")";
	}
}

// every Markdown document of the group's subtree, its properties already read;
// subgroups are descended in child order, so the page order matches the
// unbatched script's and with it which of two equal names counts as the first
function collectPages(theGroup, thePages, seen) {
	const theChildren = theGroup.children;
	const childRefs = theChildren();
	if (childRefs.length === 0)
		return;
	const childTypes = theChildren.recordType();
	const childNames = theChildren.name();
	const childUUIDs = theChildren.uuid();
	// a group of pure containers is worth no property events at all
	const batch = childTypes.indexOf("markdown") >= 0 ? readProperties(theChildren, childRefs) : null;
	for (let i = 0; i < childRefs.length; i++) {
		// a replicant is ONE record filed twice, not two pages: counting it again
		// would report its name as ambiguous and its second filing as unreachable,
		// on a wiki that is neither.  Guarding groups too keeps a replicated
		// section from being walked (and paid for) a second time.
		if (seen.has(childUUIDs[i]))
			continue;
		seen.add(childUUIDs[i]);
		if (childTypes[i] === "group")
			collectPages(childRefs[i], thePages, seen);
		else if (childTypes[i] === "markdown")
			thePages.push({
				name: childNames[i],
				uuid: childUUIDs[i],
				aliases: batch.aliases[i],
				characterCount: batch.chars[i] || 0,
				unlinked: batch.unlinked[i] || [],
				wikiRefs: batch.wikiRefs[i] || [],
				itemRefs: batch.itemRefs[i] || [],
			});
	}
}

// one event per property for the whole group, or one per record when that fails
function readProperties(theChildren, childRefs) {
	try {
		return {
			aliases: theChildren.aliases(),
			chars: theChildren.characterCount(),
			unlinked: theChildren.unlinkedWikiLinks(),
			wikiRefs: theChildren.outgoingWikiReferences.uuid(),
			itemRefs: theChildren.outgoingReferences.uuid(),
		};
	}
	catch (e) {
		const read = (fallback, get) => childRefs.map(c => {
			try { return get(c); } catch (e) { return fallback; }
		});
		return {
			aliases: read(null, c => c.aliases()),
			chars: read(0, c => c.characterCount()),
			unlinked: read([], c => c.unlinkedWikiLinks()),
			wikiRefs: read([], c => c.outgoingWikiReferences.uuid()),
			itemRefs: read([], c => c.outgoingReferences.uuid()),
		};
	}
}

// "a, b; c" -> ["a", "b", "c"]
function splitList(theText) {
	if (theText == null)
		return [];
	return String(theText).split(/[,;]/).map(part => part.trim()).filter(part => part !== "");
}
