import {
	ProcessType,
	WorkStepType,
} from "qrc:/js/lib/generated/enum";
import {
	isNodeUpdateSheetBending,
	isNodeUpdateSheetCutting,
	isNodeUpdateTubeCutting,
} from "qrc:/js/lib/generated/typeguard";
import {
	front, itemAt,
} from "./array_util";
import {
	articleUserDatum,
	articleUserDatumImpl,
	createUpdatedArticleUserData,
} from "./article_userdata_config";

import {
	checkGraphAxioms,
} from "./axioms";
import {
	tolerances,
} from "./constants";
import {
	getAssociatedSheetMaterialId,
	isSemimanufacturedArticle,
} from "./graph_utils";
import {
	computeInitialNodeUpdatesForArticle,
	computeInitialArticleParams,
	computeInitialArticleUpdates,
	computeInitialNodeUpdates,
	computePreGraphPotentialSheetNestingPartitions,
	computeSheetNestingPrePartitions,
	computeTubeNestingPrePartitions,
	gatherInitialArticleParams,
	InitialArticleParams,
} from "./pregraph_util";
import {
	findActiveProcess,
	isAvailableProcess,
	workStepTypeMap,
} from "./process";
import {
	getSettingOrDefault,
} from "./settings_table";
import {
	getSharedDataEntry,
} from "./shared_data";
import {
	computePotentialSheetNestingPartitions,
	SheetNestingPartition,
	SheetNestingPartitionTarget,
} from "./sheet_nesting_partitions";
import {
	getTable,
	tubeProfileForGeometry,
} from "./table_utils";
import {
	createUpdatedGraphUserData,
	createUpdatedNodeUserDataImpl,
	getGraphUserDataEntry,
	nodeUserDatumImpl,
} from "./userdata_config";
import {
	createUpdatedNodeUserData,
	isCompatibleToNodeUserDataEntry,
	nodeUserDatum,
	nodeUserDatumOrDefault,
} from "./userdata_utils";
import {
	assert,
	assertDebug,
	computeUniqueNames,
	exhaustiveStringTuple,
	isEqual,
} from "./utils";

function checkGraphValidity() {
	if (wsi4.util.isDebug()) {
		checkGraphAxioms();

		const processTable = getTable("process");
		wsi4.graph.vertices().forEach(v => {
			const pid = wsi4.node.processId(v);
			const pt = processTable.find(row => row.identifier === pid)?.type;
			assert(
				pt === undefined || pt === wsi4.node.processType(v),
				`Node invalid; process type inconsistent; expected: ${pt ?? "<undefined>"}; actual: ${wsi4.node.processType(v)}`,
			);
		});
	}
}

function populateSemimanufacturedArticleNames() {
	// Names are made unique in a separate step (see below);
	const defaultSuffix = "_0";

	const existingNames: string[] = [];
	const vertices: Vertex[] = [];
	const proposals: string[] = [];
	wsi4.graph.vertices()
		.filter(v => {
			const wst = wsi4.node.workStepType(v);
			return wst === "sheet" || wst === "tube";
		})
		.forEach(v => {
			const articleName = articleUserDatum("name", v);
			if (articleName === undefined) {
				vertices.push(v);
				proposals.push(wsi4.util.translate(wsi4.node.workStepType(v)) + defaultSuffix);
			} else {
				existingNames.push(articleName);
			}
		});

	const uniqueNames = computeUniqueNames(proposals, existingNames);
	assertDebug(() => uniqueNames.length === vertices.length);

	const updates = vertices.map((v, index): ArticleUpdate => ({
		vertex: v,
		articleUserData: createUpdatedArticleUserData("name", itemAt(index, uniqueNames), {}),
	}));

	wsi4.graphManipulator.applyUpdates([], updates, [], []);
}

/**
 * Update UserData of the respective associated articles
 */
export function changeArticleUserData(verticesWithUserData: VertexWithUserData[]): void {
	if (verticesWithUserData.length > 0) {
		const articleUpdates = verticesWithUserData.map(vud => (<ArticleUpdate>{
			vertex: vud.vertex,
			articleUserData: vud.userData,
		}));
		wsi4.graphManipulator.applyUpdates([], articleUpdates, [], []);
	}
}

export function changeArticleNames(verticesWithNames: readonly Readonly<[ Vertex, string ]>[]): boolean {
	if (verticesWithNames.length === 0) {
		return true;
	}

	const verticesWithUserData = verticesWithNames.map(verticesWithNames => ({
		vertex: verticesWithNames[0],
		userData: createUpdatedArticleUserData("name", verticesWithNames[1], wsi4.node.articleUserData(verticesWithNames[0])),
	}));
	changeArticleUserData(verticesWithUserData);

	return true;
}

/**
 * Change `vertex`' name to `name`
 */
export function changeArticleName(inputVertex: Vertex, name: string): boolean {
	const currentUserData = wsi4.node.articleUserData(inputVertex);
	const currentName = articleUserDatumImpl("name", currentUserData);
	if (currentName === name) {
		return true;
	}

	changeArticleUserData([
		{
			vertex: inputVertex,
			userData: createUpdatedArticleUserData("name", name, currentUserData),
		},
	]);

	return true;
}

/**
 * Change the multiplicity of the associated node.
 *
 * There also is setMultiplicity() which sets the multiplicity of all nodes allowing this.
 */
export function changeMultiplicity(vertex: Vertex, multiplicity: number): boolean {
	assertDebug(() => wsi4.node.isImport(vertex), "Expecting import node");
	changeImportMultiplicities([
		{
			vertex: vertex,
			multiplicity: multiplicity,
		},
	]);
	checkGraphValidity();
	return true;
}

/**
 * Change the multiplicity of the multiple nodes.
 *
 * There also is setMultiplicity() which sets the multiplicity of all nodes allowing this.
 *
 * Pre-condition:  Each vertex is an import node
 */
export function changeImportMultiplicities(verticesWithMultiplicities: readonly Readonly<VertexWithMultiplicity>[]): boolean {
	assertDebug(() => verticesWithMultiplicities.every(vwm => wsi4.node.isImport(vwm.vertex)));

	const sheetNestingPrePartitions = gatherAssociatedSheetNestingPrePartitions(verticesWithMultiplicities.map(vwm => vwm.vertex));
	wsi4.graphManipulator.changeImportMultiplicities(verticesWithMultiplicities, sheetNestingPrePartitions, getSettingOrDefault("tubeNestingDistance"));

	populateSemimanufacturedArticleNames();

	checkGraphValidity();

	return true;
}

/**
 * In case of an error the underlying graph will remain unchanged.
 * (Example: Unfolding of an enforced sheet metal part fails.)
 */
export function changeProcessIds(verticesWithProcessIds: readonly Readonly<{vertex: Vertex, processId: string | null}>[]): boolean {
	if (verticesWithProcessIds.length === 0) {
		return true;
	}

	const processTable = getTable("process");

	type RootIdWithProcessProps = {
		rootId: GraphNodeRootId;
		oldWorkStepType: WorkStepType;
		oldProcessType: ProcessType;
		newProcessType: ProcessType;
		newProcessId: string;
	};
	const rootIdProcessProps: RootIdWithProcessProps[] = verticesWithProcessIds.filter(obj => obj.processId !== null)
		.map(obj => {
			assert(obj.processId !== null);
			const process = processTable.find(row => row.identifier === obj.processId);
			assert(process !== undefined, "No process found for ID " + (obj.processId ?? "<undefined>"));
			return {
				rootId: wsi4.node.rootId(obj.vertex),
				oldWorkStepType: wsi4.node.workStepType(obj.vertex),
				oldProcessType: wsi4.node.processType(obj.vertex),
				newProcessType: process.type,
				newProcessId: obj.processId,
			};
		});

	const wstChangeResult = (() => {
		const verticesWithWsts: VertexWithWorkStepType[] = verticesWithProcessIds
			.filter(obj => wsi4.node.isInitial(obj.vertex))
			.map(obj => {
				if (obj.processId === null) {
					return {
						vertex: obj.vertex,
						workStepType: "undefined",
					};
				} else {
					const process = processTable.find(row => row.identifier === obj.processId);
					assert(process !== undefined, "No process found for ID " + obj.processId);
					return {
						vertex: obj.vertex,
						workStepType: workStepTypeMap[process.type],
					};
				}
			});
		return wsi4.graphManipulator.changeWorkStepTypes(verticesWithWsts);
	})();
	if (!wstChangeResult.success) {
		return false;
	}

	const preGraph = wstChangeResult.preGraph;

	const nodeUpdates: SlNodeUpdate[] = [];
	const pushProcessTypeAndIdUpdate = (slVertex: SlVertex, obj: RootIdWithProcessProps) => {
		const wst = wsi4.sl.node.workStepType(slVertex, preGraph);
		assertDebug(() => obj.oldWorkStepType === wst, "Expecting WST to be unchanged");
		switch (wst) {
			case "sheet":
			case "tube":
			case "packaging":
			case "undefined": return;
			case "joining":
			case "sheetCutting":
			case "sheetBending":
			case "tubeCutting":
			case "userDefined":
			case "userDefinedBase":
			case "transform": nodeUpdates.push({
				type: wst,
				content: {
					vertex: slVertex,
					processId: obj.newProcessId,
					processType: obj.newProcessType,
				},
			});
		}
	};

	// Assumption: Only "initial" nodes can be converted to `userDefinedBase` (which changes the nature of the associated article).
	// => If an article's initial node's ProcessType is changed, then new InitialArticleAttributes are required.
	// (The formerly supported case of a `sheetBending`-derived `sheetCutting` node that is turned into `userDefinedBase` is not supported any more.)
	//
	// Assumption: The change of a node's ProcessType (WST remains the same) does not require further recomputations
	//
	// When computing the new graph's sheet nesting partitions it is important that initial params are computed only for those articles that are also updated based on these params.
	// Otherwise the computed sheet nesting partitions might not match the new resulting graph.
	const initialArticleParams = wsi4.sl.graph.articles(preGraph)
		.filter(article => article.some(lhs => wstChangeResult.changedVertices.some(rhs => isEqual(lhs, rhs))))
		.map(article => computeInitialArticleParams(article, preGraph));

	wstChangeResult.changedVertices.forEach(slVertex => {
		const rootId = wsi4.sl.node.rootId(slVertex, preGraph);
		const obj = rootIdProcessProps.find(item => isEqual(item.rootId, rootId));
		if (obj !== undefined && obj.oldWorkStepType === wsi4.sl.node.workStepType(slVertex, preGraph)) {
			pushProcessTypeAndIdUpdate(slVertex, obj);
		} else {
			const article = wsi4.sl.graph.article(slVertex, preGraph);
			const articleParams = initialArticleParams.find(obj => isEqual(obj.article, article));
			assert(articleParams !== undefined, "No initial article params found");
			nodeUpdates.push(...computeInitialNodeUpdatesForArticle(article, preGraph, articleParams));
		}
	});

	// In case a WorkStepType update was *not* required, the associated node is unchanged at this point.
	// Adding nodeUpdates for these cases now:
	wsi4.sl.graph.vertices(preGraph)
		.filter(lhs => wstChangeResult.changedVertices.every(rhs => !isEqual(lhs, rhs)))
		.forEach(v => {
			const rootId = wsi4.sl.node.rootId(v, preGraph);
			const obj = rootIdProcessProps.find(obj => isEqual(obj.rootId, rootId));
			if (obj === undefined) {
				return;
			}
			// Update a previously existing node
			pushProcessTypeAndIdUpdate(v, obj);
		});

	const newSheetTargets = wstChangeResult.changedVertices.filter(v => wsi4.sl.node.workStepType(v, preGraph) === "sheetCutting");
	newSheetTargets.push(...wsi4.sl.graph.vertices(preGraph).filter(vertex => {
		const article = wsi4.sl.graph.article(vertex, preGraph);
		return article.some(v => wsi4.sl.node.workStepType(v, preGraph) === "sheetCutting" && wsi4.sl.graph.sources(front(article), preGraph).length === 0);
	}));

	const sheetProcess = processTable.find(row => row.active && row.type === "sheet" && isAvailableProcess(row, processTable));

	// Note: This potentially computes more data than required...
	const sheetNestingPartitions = computePreGraphPotentialSheetNestingPartitions(preGraph, initialArticleParams);
	const sheetNestingPrePartitions: SlSheetNestingPrePartition[] = [];
	sheetNestingPartitions.forEach(partition => {
		const nestingOutdated = partition.ids.some(v => {
			if (newSheetTargets.some(other => isEqual(v, other))) {
				// There is a nodeUpdate for a partition member, so recomputation of the nesting is required
				return true;
			}

			// If the WorkStepType of a target of an existing sheet node has been changed, a recomputation of the nesting is required
			const rootId = wsi4.sl.node.rootId(v, preGraph);
			return rootIdProcessProps.some(obj => isEqual(rootId, obj.rootId)) || wstChangeResult.changedVertices.some(other => isEqual(v, other));
		});
		if (!nestingOutdated) {
			return;
		}

		const input: SlSheetNestingPrePartition = {
			compatibleTargets: partition.ids.map(v => ({
				vertex: v,
				fixedRotations: [],
				sheetFilterSheetIds: [],
			})),
			nestingDistance: getSettingOrDefault("sheetNestingDistance"),
			nestorConfig: {
				timeout: getSharedDataEntry("nestorTimeLimit"),
				numRelevantNestings: 0,
			},
		};
		if (partition.sheetMaterialId !== undefined) {
			input.sheetMaterialId = partition.sheetMaterialId;
		}
		if (sheetProcess !== undefined) {
			input.sheetProcessId = sheetProcess.identifier;
		}
		sheetNestingPrePartitions.push(input);
	});

	const tubeNestingPrePartitions = computeTubeNestingPrePartitions(
		nodeUpdates.filter(nu => nu.type === "tubeCutting")
			.map(nu => {
				const targetVertex = nu.content.vertex;
				const article = wsi4.sl.graph.article(targetVertex, preGraph);
				return initialArticleParams.find(params => isEqual(params.article, article));
			})
			// The are not matching params in case the WST did *not* change.
			// In this case there also is no need to recompute the nesting.
			.filter((params): params is InitialArticleParams => params !== undefined),
	);

	assertDebug(
		() => nodeUpdates.every((lhs, lhsIndex, self) => self.find((rhs, rhsIndex) => !isEqual(lhs, rhs) || lhsIndex === rhsIndex)),
		"nodeUpdates ambiguous for at least one node",
	);

	const articleUpdates = (() => {
		const articles = initialArticleParams
			.filter(params => articleUserDatumImpl("name", wsi4.sl.node.articleUserData(front(params.article), preGraph)) === undefined)
			.map(params => params.article);
		const existingNames = wsi4.graph.articles().map(article => articleUserDatum("name", front(article)) ?? "");
		return computeInitialArticleUpdates(articles, preGraph, new Map<string, string>(), existingNames);
	})();

	const graph = wsi4.sl.graph.finalizePreGraph(preGraph, nodeUpdates, articleUpdates, sheetNestingPrePartitions, tubeNestingPrePartitions);
	wsi4.graphManipulator.set(graph);

	populateSemimanufacturedArticleNames();

	return true;
}

export function changeNodeProcessId(vertex: Vertex, processId: string | null): boolean {
	const result = changeProcessIds([
		{
			vertex: vertex,
			processId: processId,
		},
	]);
	checkGraphValidity();
	return result;
}

export function changeNodesProcessId(data: readonly VertexWithProcessTypeData[]): boolean {
	const result = changeProcessIds(data);
	checkGraphValidity();
	return result;
}

function sheetNestingPartitionsForThicknessUpdate(sheetCuttingVertices: readonly Vertex[], verticesWithSheetThicknesses: readonly Readonly<VertexWithSheetThickness>[]): SheetNestingPartition<Vertex>[] {
	const getThickness = (vertex: Vertex) => {
		const obj = verticesWithSheetThicknesses.find(obj => isEqual(obj.vertex, vertex));
		const thickness = obj?.sheetThickness ?? wsi4.node.sheetThickness(vertex);
		assert(thickness !== undefined);
		return thickness;
	};
	const targets = sheetCuttingVertices.map((vertex): SheetNestingPartitionTarget<Vertex> => {
		assertDebug(() => wsi4.node.workStepType(vertex) === "sheetCutting", "Pre-condition violated");
		return <SheetNestingPartitionTarget<Vertex>> {
			id: vertex,
			processIdSheetCutting: wsi4.node.processId(vertex),
			thickness: getThickness(vertex),
			sheetMaterialId: getAssociatedSheetMaterialId(vertex),
		};
	});
	return computePotentialSheetNestingPartitions(targets);
}

export function changeSheetThicknesses(inputs: readonly Readonly<VertexWithSheetThickness>[]): void {
	if (!inputs.some(obj => wsi4.node.workStepType(obj.vertex) === "sheetCutting" || !wsi4.node.isImport(obj.vertex))) {
		wsi4.throwError("Cannot change thickness of node that is not based on 2D import");
	}

	const nodeUpdates = inputs.map(obj => (<NodeUpdate>{
		type: "sheetCutting",
		content: <NodeUpdateSheetCutting>{
			vertex: obj.vertex,
			sheetThickness: obj.sheetThickness,
		},
	}));

	const relatedSheetCuttingVertices = (() => {
		const vertices: Vertex[] = [];
		inputs.forEach(obj => {
			const sheetSource = wsi4.graph.reaching(obj.vertex)
				.find(v => wsi4.node.workStepType(v) === "sheet");
			assert(sheetSource !== undefined, "Expecting reaching sheet node for each sheetCutting node");
			vertices.push(...wsi4.graph.reachable(sheetSource).filter(v => wsi4.node.workStepType(v) === "sheetCutting"));
		});

		const relatedSheetMaterials = inputs.map(obj => getAssociatedSheetMaterialId(obj.vertex))
			.filter((id): id is string => id !== undefined)
			.filter((lhs, index, self) => self.findIndex(rhs => lhs === rhs) === index);

		vertices.push(...wsi4.graph.vertices()
			.filter(v => {
				if (wsi4.node.workStepType(v) !== "sheetCutting" || inputs.some(obj => isEqual(v, obj.vertex))) {
					return false;
				}

				const thickness = wsi4.node.sheetThickness(v) ?? 0;
				if (inputs.every(input => Math.abs(input.sheetThickness - thickness) > tolerances.thickness)) {
					return false;
				}

				const materialId = getAssociatedSheetMaterialId(v);
				return materialId !== undefined && relatedSheetMaterials.some(id => id === materialId);
			}));

		vertices.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index);
		return vertices;
	})();

	const processTable = getTable("process");
	const sheetProcess = processTable.find(row => row.active && row.type === "sheet" && isAvailableProcess(row, processTable));

	const sheetNestingPartitions = sheetNestingPartitionsForThicknessUpdate(relatedSheetCuttingVertices, inputs);
	const sheetNestingPrePartitions = sheetNestingPartitions.filter(partition => partition.ids.some(lhs => relatedSheetCuttingVertices.some(rhs => isEqual(lhs, rhs))))
		.map(partition => {
			const input: SheetNestingPrePartition = {
				compatibleTargets: partition.ids.map(v => ({
					vertex: v,
					fixedRotations: [],
					sheetFilterSheetIds: [],
				})),
				nestingDistance: getSettingOrDefault("sheetNestingDistance"),
				nestorConfig: {
					timeout: getSharedDataEntry("nestorTimeLimit"),
					numRelevantNestings: 0,
				},
			};
			if (partition.sheetMaterialId !== undefined) {
				input.sheetMaterialId = partition.sheetMaterialId;
			}
			if (sheetProcess !== undefined) {
				input.sheetProcessId = sheetProcess.identifier;
			}
			return input;
		});

	wsi4.graphManipulator.applyUpdates(nodeUpdates, [], sheetNestingPrePartitions, []);

	populateSemimanufacturedArticleNames();

	checkGraphValidity();
}

export function changeNodeThickness(vertex: Vertex, thickness: number): void {
	changeSheetThicknesses([
		{vertex: vertex,
			sheetThickness: thickness},
	]);
}

export function changeGraphUserData(graphUserData: StringIndexedInterface): void {
	wsi4.graphManipulator.changeGraphUserData(graphUserData);
	checkGraphValidity();
}

export function changeProjectName(name: string): void {
	wsi4.graphManipulator.changeGraphUserData(createUpdatedGraphUserData("name", name));
	checkGraphValidity();
}

export function clearGraph(): void {
	wsi4.graphManipulator.clear();
	checkGraphValidity();
}

export function createExternalNode(processId?: string, nodeUserData: StringIndexedInterface = {}, articleUserData: StringIndexedInterface = {}): Vertex {
	return wsi4.graphManipulator.createUserDefinedBaseNode(processId ?? "", nodeUserData, articleUserData);
}

function gatherAssociatedSheetNestingPrePartitions(inputVertices: Vertex[]): SheetNestingPrePartition[] {
	const sheetVertices = (() => {
		const result: Vertex[] = [];
		inputVertices.forEach(v => {
			result.push(...wsi4.graph.reaching(v).filter(v => {
				const wst = wsi4.node.workStepType(v);
				return wst === "sheet";
			}));
		});
		return result.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index);
	})();

	const result: SheetNestingPrePartition[] = [];
	sheetVertices.forEach(semimanufacturedVertex => {
		const wst = wsi4.node.workStepType(semimanufacturedVertex);
		switch (wst) {
			case "sheet": {
				const targets = wsi4.graph.targets(semimanufacturedVertex);
				result.push({
					compatibleTargets: targets.map(v => {
						const userData = wsi4.node.userData(v);
						return {
							vertex: v,
							fixedRotations: nodeUserDatumImpl("fixedRotations", userData) ?? [],
							sheetFilterSheetIds: nodeUserDatumImpl("sheetFilterSheetIds", userData) ?? [],
						};
					}),
					nestingDistance: getSettingOrDefault("tubeNestingDistance"),
					nestorConfig: {
						timeout: getSharedDataEntry("nestorTimeLimit"),
						numRelevantNestings: 0,
					},
				});
				break;
			}
			default: assertDebug(() => false, "Unexpected WorkStepType: " + wst); break;
		}
	});

	return result;
}

/**
 * Delete articles associated with vertices
 *
 * Only non-semimanufactured articles can be deleted.
 */
export function deleteArticles(vertices: Vertex[]): void {
	assertDebug(() => vertices.every(v => {
		const article = wsi4.graph.article(v);
		return !isSemimanufacturedArticle(article);
	}), "Pre-condition violated");

	if (vertices.length === 0) {
		return;
	}

	const sheetNestingPrePartitions = gatherAssociatedSheetNestingPrePartitions(vertices);
	wsi4.graphManipulator.deleteArticles(vertices, sheetNestingPrePartitions);

	populateSemimanufacturedArticleNames();

	checkGraphValidity();
}

export function deleteArticle(vertex: Vertex): void {
	return deleteArticles([ vertex ]);
}

/**
 * Delete nodes of type userDefined or transform
 *
 * Only non-semimanufactured articles can be deleted.
 */
export function deleteNodes(inputVertices: Vertex[]): void {
	// In order to maintain the API contract, deletion of articles is also supported for now.
	// However, it is preferrable to do so explicitly via deleteArticles().
	const simpleNodeVertices: Vertex[] = [];
	const articlesToDelete: GraphNodeRootId[] = [];

	inputVertices.forEach(v => {
		const wst = wsi4.node.workStepType(v);
		if (wst === "userDefined" || wst === "transform") {
			simpleNodeVertices.push(v);
		} else {
			articlesToDelete.push(wsi4.node.rootId(v));
		}
	});

	wsi4.graphManipulator.deleteNodes(simpleNodeVertices);

	deleteArticles(articlesToDelete
		.map(rootId => wsi4.node.vertexFromRootId(rootId))
		.filter((v): v is Vertex => v !== undefined));

	checkGraphValidity();
}

/**
 * Delete node of type userDefined or transform
 */
export function deleteNode(vertex: Vertex): void {
	return deleteNodes([ vertex ]);
}

export function appendNode(source: Vertex, processId: string, processTypeInput?: ProcessType, nodeUserData?: StringIndexedInterface): Vertex {
	const processType = processTypeInput ?? (() => {
		const process = getTable("process").find(row => row.identifier === processId);
		assert(process !== undefined, "No Process found for ID " + processId);
		return process.type;
	})();
	const wst = workStepTypeMap[processType];
	assert(wst === "userDefined" || wst === "transform", `Cannot append node with WorkStepType ${wst}.  Only userDefinedBase and transform are supported.`);
	const params: NewNodeParams = {
		processType: processType,
		processId: processId,
		nodeUserData: nodeUserData ?? {},
	};

	const result = wsi4.graphManipulator.appendNode(source, params);
	checkGraphValidity();
	return result;
}

export function prependNode(target: Vertex, processId: string, processTypeInput?: ProcessType, nodeUserData?: StringIndexedInterface): Vertex {
	const processType = processTypeInput ?? (() => {
		const process = getTable("process").find(row => row.identifier === processId);
		assert(process !== undefined, "No Process found for ID " + processId);
		return process.type;
	})();
	const wst = workStepTypeMap[processType];
	assert(wst === "userDefined" || wst === "transform", `Cannot append node with WorkStepType ${wst}.  Only userDefinedBase and transform are supported.`);
	const params: NewNodeParams = {
		processType: processType,
		processId: processId,
		nodeUserData: nodeUserData ?? {},
	};

	const result = wsi4.graphManipulator.prependNode(target, params);
	checkGraphValidity();
	return result;
}

export function appendNodeWithProcessId(processId: string, inputVertex: Vertex): Vertex {
	return appendNode(inputVertex, processId);
}

export function prependNodeWithProcessId(processId: string, inputVertex: Vertex): Vertex {
	return prependNode(inputVertex, processId);
}

export function redo(): boolean {
	const r = wsi4.graphManipulator.redo();
	// FIXME see #2047
	// Cannot graph validity at this point as an (from the script env point of view) invalid graph might be stored as part of the undo history
	// (e.g. partially initialized article attributes)
	// checkGraphValidity();
	return r;
}

export function undo(): boolean {
	const u = wsi4.graphManipulator.undo();
	// FIXME see #2047
	// Cannot graph validity at this point as an (from the script env point of view) invalid graph might be stored as part of the undo history
	// (e.g. partially initialized article attributes)
	// checkGraphValidity();
	return u;
}

// Set multiplicity of all nodes that provide ImportGraphNodeAttributes
export function setMultiplicity(multiplicity: number): boolean {
	if (multiplicity < 1) {
		return wsi4.throwError("Multiplicity invalid. Expecting value >= 1. Got " + multiplicity.toString());
	}
	const verticesWithMultiplicities = wsi4.graph.vertices()
		.filter(v => (wsi4.node.isImport(v)))
		.map(v => ({
			vertex: v,
			multiplicity: multiplicity,
		}));
	return changeImportMultiplicities(verticesWithMultiplicities);
}

export function changeJoining(vertex: Vertex, joining: Joining): void {
	wsi4.graphManipulator.changeJoining(vertex, joining);
	checkGraphValidity();
}

/**
 * Change UserData for a set of nodes
 *
 * For sheet metal part specific UserData consider using updateSheetMetalParts() instead
 * in order to ensure the associated sheet nestings are managed accordingly.
 *
 * For tube part specific UserData consider using updateTubeParts() instead
 * in order to ensure the associated tube nestings are managed accordingly.
 */
export function changeNodesUserData(verticesWithUserData: VertexWithUserData[]): boolean {
	const nodeUpdates: NodeUpdate[] = [];
	verticesWithUserData.forEach(vud => {
		const vertex = vud.vertex;
		const wst = wsi4.node.workStepType(vertex);
		type SupportedUpdate = NodeUpdateJoining
		| NodeUpdateSheetCutting
		| NodeUpdateSheetBending
		| NodeUpdateTubeCutting
		| NodeUpdateUserDefined
		| NodeUpdateUserDefinedBase
		| NodeUpdateTransform
		| NodeUpdateSheet
		| NodeUpdateTube;
		switch (wst) {
			case "joining":
			case "sheet":
			case "sheetBending":
			case "sheetCutting":
			case "transform":
			case "tube":
			case "tubeCutting":
			case "userDefined":
			case "userDefinedBase": {
				const content: SupportedUpdate = {
					vertex: vertex,
					nodeUserData: vud.userData,
				};
				nodeUpdates.push({
					type: wst,
					content: content,
				});
				break;
			}
			case "packaging":
			case "undefined": {
				wsi4.throwError("Cannot set UserData for node of type " + wst);
			}
		}
	});
	wsi4.graphManipulator.applyUpdates(nodeUpdates, [], [], []);
	checkGraphValidity();
	return true;
}

/**
 * Change a node's UserData
 *
 * For sheet metal part specific UserData consider using updateSheetMetalParts() instead
 * in order to ensure the associated sheet nestings are managed accordingly.
 *
 * For tube part specific UserData consider using updateTubeParts() instead
 * in order to ensure the associated tube nestings are managed accordingly.
 */
export function changeNodeUserData(vertex: Vertex, userData: StringIndexedInterface): boolean {
	return changeNodesUserData([
		{
			vertex: vertex,
			userData: userData,
		},
	]);
}

export function changeSheetFilterSheetIdsOfSheet(vertex: Vertex, sheetFilterSheetIds: string[]): boolean {
	assert(wsi4.node.workStepType(vertex) === WorkStepType.sheet, "Wrong workStepType.");
	const updates: SheetMetalPartUpdate[] = wsi4.graph.reachable(vertex)
		.filter(v => wsi4.node.workStepType(v) === "sheetCutting")
		.map(v => ({
			vertex: v,
			sheetFilterSheetIds: sheetFilterSheetIds,
		}));
	updateSheetMetalParts(updates);
	return true;
}

/**
 * Compute sheet updates based on the current targets of all related sheet nodes
 *
 * For certain operations only a re-nesting (not re-partitioning) is required.
 * (E.g. toggle upper side; change bend correction; etc.)
 *
 * In this case the sheet node updates required for re-nesting can be computed from the current partitions.
 */
function sheetNestingPrePartitionsFromCurrentTargets(targets: readonly Vertex[]): SheetNestingPrePartition[] {
	assertDebug(() => targets.every(v => wsi4.node.workStepType(v) === "sheetCutting" || wsi4.node.workStepType(v) === "sheetBending"), "Pre-condition violated");

	const sheetVertices: Vertex[] = [];
	targets.forEach(target => {
		const newReachingSheets = wsi4.graph.reaching(target).filter(r => wsi4.node.workStepType(r) === "sheet")
			.filter(v => sheetVertices.every(other => !isEqual(other, v)));
		sheetVertices.push(...newReachingSheets);
	});

	const nestorConfig: CamNestorConfig = {
		timeout: getSharedDataEntry("nestorTimeLimit"),
		numRelevantNestings: 0,
	};

	const nestingDistance = getSettingOrDefault("sheetNestingDistance");

	return sheetVertices.map(sheetVertex => {
		const input: SheetNestingPrePartition = {
			sheetProcessId: wsi4.node.processId(sheetVertex),
			nestorConfig: nestorConfig,
			nestingDistance: nestingDistance,
			compatibleTargets: wsi4.graph.targets(sheetVertex).map(target => {
				const nodeUserData = wsi4.node.userData(target);
				return {
					vertex: target,
					fixedRotations: nodeUserDatumOrDefault("fixedRotations", target, nodeUserData),
					sheetFilterSheetIds: nodeUserDatumOrDefault("sheetFilterSheetIds", target, nodeUserData),
				};
			}),
		};

		const sheetMaterialId = getAssociatedSheetMaterialId(sheetVertex);
		if (sheetMaterialId !== undefined) {
			input.sheetMaterialId = sheetMaterialId;
		}

		return input;
	});
}

/**
 * Note: The new original BendGraph can differ slightly from the old one due to numerical inaccuracies.
 * Hence, the BendDieChoices should not be compared for equality, but for closeness instead.
 */
export function toggleUpperSide(vertices: readonly Vertex[]) {
	const nodeUpdates: NodeUpdate[] = vertices.map(v => {
		const wst = wsi4.node.workStepType(v);
		assertDebug(() => wst === "sheetCutting" || wst === "sheetBending", `Pre-condition violated (WST: ${wst})`);
		const content: NodeUpdateSheetCutting | NodeUpdateSheetBending = {
			vertex: v,
			toggleUpperSide: true,
		};
		return {
			type: wst,
			content: content,
		};
	});

	const sheetNestingPrePartitions = sheetNestingPrePartitionsFromCurrentTargets(vertices);

	wsi4.graphManipulator.applyUpdates(nodeUpdates, [], sheetNestingPrePartitions, []);
	checkGraphValidity();
}

export function changeBendCorrectionFlag(vertices: readonly Vertex[], correctBends: boolean) {
	const nodeUpdates: NodeUpdate[] = vertices.map(v => {
		const wst = wsi4.node.workStepType(v);
		assertDebug(() => wst === "sheetBending", "Pre-condition violated");
		const content: NodeUpdateSheetBending = {
			vertex: v,
			correctBends: correctBends,
		};
		return {
			type: wst,
			content: content,
		};
	});

	// Changing the bend correction cannot affect the partitioning so re-using the existing partitions
	const sheetNestingPrePartitions = sheetNestingPrePartitionsFromCurrentTargets(vertices);

	wsi4.graphManipulator.applyUpdates(nodeUpdates, [], sheetNestingPrePartitions, []);
	checkGraphValidity();
}

export function changeDieChoiceMap(vertex: Vertex, dieChoiceMap: readonly Readonly<DieChoiceMapEntry>[]): boolean {
	updateSheetMetalParts([
		{
			vertex: vertex,
			dieChoiceMap: Array.from(dieChoiceMap),
		},
	]);
	checkGraphValidity();
	return true;
}

export function setCommands(vertex: Vertex, commands: CamCommand[]): void {
	wsi4.graphManipulator.setCommands(vertex, commands);
	checkGraphValidity();
}

function articleHasDeburringNode(article: readonly Vertex[]): boolean {
	return article.some(vertex => {
		const processType = wsi4.node.processType(vertex);
		return processType === ProcessType.automaticMechanicalDeburring
			|| processType === ProcessType.manualMechanicalDeburring;
	});
}

function insertDeburringNodes(processType: ProcessType, terminatingRootIds: readonly GraphNodeRootId[], deburrDoubleSided: boolean) {
	const process = findActiveProcess(p => p.type === processType);
	if (process === undefined) {
		wsi4.util.warn("Cannot insert deburring node(s).  No active process found for process type " + processType);
	} else {
		const sheetMaterialConstraints = getTable("processConstraintsSheetMaterial").filter(row => row.processId === process.identifier);
		const permittedSheetMaterialIds = sheetMaterialConstraints.filter(row => row.isAllowed).map(row => row.sheetMaterialId);
		const forbiddenSheetMaterialIds = sheetMaterialConstraints.filter(row => !row.isAllowed).map(row => row.sheetMaterialId);
		terminatingRootIds.map(rootId => {
			const vertex = wsi4.node.vertexFromRootId(rootId);
			assert(vertex !== undefined, "Expecting valid vertex for rootId " + wsi4.util.toKey(rootId));
			return vertex;
		})
			.reduce((acc: Vertex[], vertex) => ([
				...acc,
				...wsi4.graph.reaching(vertex),
				vertex,
			]), [])
			.filter(vertex => wsi4.node.workStepType(vertex) === "sheetCutting"
				&& !articleHasDeburringNode(wsi4.graph.article(vertex))
				&& (() => {
					const sheetMaterialId = getAssociatedSheetMaterialId(vertex);
					return sheetMaterialId !== undefined
						&& (permittedSheetMaterialIds.length === 0 || permittedSheetMaterialIds.includes(sheetMaterialId))
						&& !forbiddenSheetMaterialIds.includes(sheetMaterialId);
				})(),
			)
			.map(vertex => wsi4.node.rootId(vertex))
			.forEach(rootId => {
				const sourceVertex = wsi4.node.vertexFromRootId(rootId);
				assert(sourceVertex !== undefined, "Expecting valid vertex for RootId " + wsi4.util.toKey(rootId));
				const newVertex = appendNodeWithProcessId(process.identifier, sourceVertex);
				if (newVertex === undefined) {
					wsi4.util.warn("Automatic node insertion failed for process type " + processType);
					return;
				}

				const userData = createUpdatedNodeUserData("deburrDoubleSided", newVertex, deburrDoubleSided);
				changeNodeUserData(newVertex, userData);
			});
	}
}

function deburrIfRequired(terminatingRootIds: readonly GraphNodeRootId[]) {
	const config = getSharedDataEntry("automaticProcessConfig");
	if (config.deburringMode !== undefined) {
		switch (config.deburringMode) {
			case "automaticSingleSided": {
				insertDeburringNodes(ProcessType.automaticMechanicalDeburring, terminatingRootIds, false);
				break;
			}
			case "automaticDoubleSided": {
				insertDeburringNodes(ProcessType.automaticMechanicalDeburring, terminatingRootIds, true);
				break;
			}
			case "manualSingleSided": {
				insertDeburringNodes(ProcessType.manualMechanicalDeburring, terminatingRootIds, false);
				break;
			}
			case "manualDoubleSided": {
				insertDeburringNodes(ProcessType.manualMechanicalDeburring, terminatingRootIds, true);
				break;
			}
		}
	}
}

/**
 * Post-process a sub-graph
 * @param terminatingRootIds Root IDs defining the sub-graph
 */
function postProcessSubGraph(terminatingRootIds: readonly GraphNodeRootId[]): void {
	deburrIfRequired(terminatingRootIds);
}

function addToGraphImpl(graphCreatorInputs: readonly Readonly<GraphCreatorInput>[], cadImportConfig: Readonly<CadImportConfig>, importIdNameProposalMap: Map<string, string>): void {
	if (graphCreatorInputs.length === 0) {
		return;
	}

	const [
		preGraph,
		newArticles,
	] = (() => {
		const newPreGraph = wsi4.sl.graph.create(graphCreatorInputs, cadImportConfig);
		const articleRootIds = wsi4.sl.graph.articles(newPreGraph).map(a => wsi4.sl.node.rootId(front(a), newPreGraph));
		const currentGraph = wsi4.graph.get();
		const mergedGraph = wsi4.sl.graph.mergePreGraph(currentGraph, newPreGraph);
		const articles = articleRootIds.map(rootId => {
			const v = wsi4.sl.node.vertexFromRootId(rootId, mergedGraph);
			assert(v !== undefined);
			return wsi4.sl.graph.article(v, mergedGraph);
		});
		return [
			mergedGraph,
			articles,
		];
	})();
	const initialArticleParams = gatherInitialArticleParams(preGraph, newArticles);

	const sheetNestingPartitions = computePreGraphPotentialSheetNestingPartitions(preGraph, initialArticleParams);
	const sheetNestingPrePartitions = computeSheetNestingPrePartitions(sheetNestingPartitions);
	const tubeNestingPrePartitions = computeTubeNestingPrePartitions(initialArticleParams);

	const initialNodeUpdates = computeInitialNodeUpdates(initialArticleParams, preGraph);

	const existingNames = wsi4.graph.articles().map(article => articleUserDatum("name", front(article)) ?? "");
	const initialArticleUpdates = computeInitialArticleUpdates(newArticles, preGraph, importIdNameProposalMap, existingNames);

	const graph = wsi4.sl.graph.finalizePreGraph(preGraph, initialNodeUpdates, initialArticleUpdates, sheetNestingPrePartitions, tubeNestingPrePartitions);
	wsi4.graphManipulator.set(graph);

	populateSemimanufacturedArticleNames();

	if (getGraphUserDataEntry("name") === undefined) {
		const projectName = Array.from(importIdNameProposalMap.values()).filter(s => s.length > 0)[0];
		if (projectName !== undefined) {
			changeProjectName(projectName);
		}
	}

	checkGraphValidity();
}

export interface AddResult {
	importId: string;
	status: AddResultStatus;
	vertices: Vertex[];
}

/**
 * Add data to graph
 * @param graphCreatorInputs Data that should be added to graph
 * @param cadImportConfig Configuration for the CAD import
 * @param importIdNameProposalMap Optional mapping input id to a name proposal for corresponding vertices
 * @returns [[AddResult]]s for the respective [[Input]]s
 */
export function addToGraph(graphCreatorInputs: readonly Readonly<GraphCreatorInput>[], cadImportConfig: Readonly<CadImportConfig>, importIdNameProposalMap = new Map<string, string>()): AddResult[] {
	addToGraphImpl(graphCreatorInputs, cadImportConfig, importIdNameProposalMap);
	const newRootIds = (() => {
		const rootIds: GraphNodeRootId[] = [];
		wsi4.graph.vertices().forEach(v => {
			const importId = wsi4.node.importId(v);
			if (importId === undefined) {
				return;
			}
			if (graphCreatorInputs.every(gci => gci.content.importId !== importId)) {
				return;
			}
			rootIds.push(wsi4.node.rootId(v));
		});
		return rootIds;
	})();

	// Note:  This (potentially) changes the graph and hence invalidates originalAddResults's underlying vertices
	postProcessSubGraph(newRootIds);

	populateSemimanufacturedArticleNames();

	const importIdVerticesMap = new Map<string, Vertex[]>();
	newRootIds.forEach(rootId => {
		const vertex = wsi4.node.vertexFromRootId(rootId);
		assert(vertex !== undefined);
		const importId = wsi4.node.importId(vertex);
		assert(importId !== undefined);
		const vertices = importIdVerticesMap.get(importId) ?? [];
		vertices.push(vertex);
		importIdVerticesMap.set(importId, vertices);
	});

	const results = Array.from(importIdVerticesMap.entries()).map(([
		importId,
		vertices,
	]): AddResult => ({
		importId: importId,
		status: "success",
		vertices: vertices,
	}));

	graphCreatorInputs.filter(gci => !importIdVerticesMap.has(gci.content.importId))
		.forEach(gci => results.push({
			importId: gci.content.importId,
			status: "undefinedError",
			vertices: [],
		}));

	return results;
}

/**
 * Common interface for updates of data that potentially require nesting recomputations
 *
 * When data of a sheet nesting's target nodes that potentially require a re-nesting
 * then the new nesting partitions are computed and applied (if there is a change).
 *
 * Toggling the upper side is *not* handled here:
 * - Computation of `BendDieChoice`s for *new* original BendGraph is not possible in advance.
 * - There should be no need to update the `BendDieChoice`s anyhow.
 * => Update via separate function and with simplified re-nesting.
 */
export interface SheetMetalPartUpdate {
	/**
	 * Any vertex of a sheet metal part article
	 */
	vertex: Vertex;
	sheetMaterialId?: string;
	fixedRotations?: number[];
	sheetFilterSheetIds?: string[];
	dieChoiceMap?: DieChoiceMapEntry[];

	/**
	 * New Process ID for the associated sheetCutting node.
	 * Not to be confused with an ID of the table sheetCuttingProcess.
	 */
	processIdSheetCutting?: string;
}

/**
 * Compute sheet nesting partitions for the current graph.
 * If data is not part of `updates` it is looked up in the current graph.
 *
 * _All_ partitions are computed - regardless of the vertices entailed in `updates`.
 */
function sheetNestingPartitionsWithUpdates(updates: readonly Readonly<SheetMetalPartUpdate>[]): SheetNestingPartition<Vertex>[] {
	const targets: SheetNestingPartitionTarget<Vertex>[] = [];
	wsi4.graph.articles()
		.forEach(article => {
			const sheetCuttingVertex = article.find(v => wsi4.node.workStepType(v) === "sheetCutting");
			if (sheetCuttingVertex === undefined) {
				return;
			}

			const thickness = wsi4.node.sheetThickness(sheetCuttingVertex);
			assert(thickness !== undefined, "Expecting valid sheet thickness for sheetCutting ndoe");

			const target: SheetNestingPartitionTarget<Vertex> = {
				id: sheetCuttingVertex,
				processIdSheetCutting: wsi4.node.processId(sheetCuttingVertex),
				thickness: thickness,
			};

			const sheetMaterialId = ((): string | undefined => {
				const update = updates.find(u => u.sheetMaterialId !== undefined && article.some(v => isEqual(u.vertex, v)));
				if (update !== undefined) {
					assert(update.sheetMaterialId !== undefined);
					return update.sheetMaterialId;
				}
				const sheetMaterialVertex = article.find(v => isCompatibleToNodeUserDataEntry("sheetMaterialId", v));
				if (sheetMaterialVertex !== undefined) {
					return nodeUserDatum("sheetMaterialId", sheetMaterialVertex);
				}
				return undefined;
			})();
			if (sheetMaterialId !== undefined) {
				target.sheetMaterialId = sheetMaterialId;
			}

			const processId = (() => {
				const update = updates.find(u => u.processIdSheetCutting !== undefined && article.some(v => isEqual(u.vertex, v)));
				return update?.processIdSheetCutting ?? wsi4.node.processId(sheetCuttingVertex);
			})();
			if (processId !== undefined) {
				target.processIdSheetCutting = processId;
			}

			targets.push(target);
		});
	return computePotentialSheetNestingPartitions(targets);
}

export function updateDieChoice(vertex: Vertex, newSheetMaterialId: string): DieChoiceMapEntry[] {
	assertDebug(() => wsi4.node.workStepType(vertex) === "sheetBending", "Pre-condition violated");
	const newCandidatesPerBend = wsi4.node.dieChoiceAlternatives(vertex, newSheetMaterialId);
	return (wsi4.node.dieChoiceMap(vertex) ?? []).map(currentMapEntry => {
		const candidates = newCandidatesPerBend.find(entry => entry.bendDescriptor === currentMapEntry.bendDescriptor)?.bendDieChoices;
		assert(candidates !== undefined);
		const currentChoice = currentMapEntry.bendDieChoice;
		// If it is possible to switch from neutralAxis to a legal (i.e. earlier sorted) non-neutralAxis choice, then this update is performed.
		const updatedChoice = candidates.find(
			candidate => (currentChoice.type === "neutralAxis" && candidate.type !== "neutralAxis")
				|| (candidate.upperDieGroupId === currentChoice.upperDieGroupId && candidate.lowerDieGroupId === currentChoice.lowerDieGroupId),
		) ?? candidates[0];
		assert(updatedChoice !== undefined, "Expecting at least neutral axis pseudo tool");
		return {
			bendDescriptor: currentMapEntry.bendDescriptor,
			bendDieChoice: updatedChoice,
		};
	});
}

/**
 * Update sheet metal part related properties that might require a recomputation of underlying nestings or die choices.
 */
export function updateSheetMetalParts(updates: readonly Readonly<SheetMetalPartUpdate>[]) {
	if (updates.length === 0) {
		// Nothing left to do
		return;
	}

	const nodeUpdates: NodeUpdate[] = [];

	const findOrCreateNodeUpdate = (vertex: Vertex, wst: "sheetBending" | "sheetCutting"): NodeUpdateSheetBending | NodeUpdateSheetCutting => {
		const update = nodeUpdates.find(nu => "vertex" in nu.content && isEqual(nu.content.vertex, vertex));
		if (update !== undefined) {
			assertDebug(() => isNodeUpdateSheetBending(update.content) || isNodeUpdateSheetCutting(update.content));
			return update.content;
		} else {
			const content: NodeUpdateSheetBending | NodeUpdateSheetCutting = {
				vertex: vertex,
			};
			nodeUpdates.push({
				type: wst,
				content: content,
			});
			return content;
		}
	};

	updates.forEach(update => {
		type DataFields = Omit<SheetMetalPartUpdate, "vertex">;
		const keys = exhaustiveStringTuple<keyof DataFields>()(
			"sheetMaterialId",
			"processIdSheetCutting",
			"fixedRotations",
			"sheetFilterSheetIds",
			"dieChoiceMap",
		);

		const article: readonly Vertex[] = wsi4.graph.article(update.vertex);

		keys.forEach((key): boolean => {
			if (update[key] === undefined) {
				return true;
			}
			switch (key) {
				case "sheetMaterialId": {
					const vertex = article.find(v => isCompatibleToNodeUserDataEntry("sheetMaterialId", v));
					assert(vertex !== undefined, "Expecting compatible node for sheetMaterialId");
					const wst = wsi4.node.workStepType(vertex);
					assert(wst === "sheetBending" || wst === "sheetCutting");
					const nu = findOrCreateNodeUpdate(vertex, wst);
					assert(update.sheetMaterialId !== undefined);
					nu.nodeUserData = createUpdatedNodeUserDataImpl("sheetMaterialId", update.sheetMaterialId, nu.nodeUserData ?? wsi4.node.userData(vertex));
					return true;
				}
				case "processIdSheetCutting": {
					const vertex = article.find(v => wsi4.node.workStepType(v) === "sheetCutting");
					assert(vertex !== undefined, "Expecting sheetCutting node in article");
					const nu = findOrCreateNodeUpdate(vertex, "sheetCutting");
					assert(update.processIdSheetCutting !== undefined);
					nu.processId = update.processIdSheetCutting;
					const pt = getTable("process").find(row => row.identifier === update.processIdSheetCutting)?.type;
					if (pt !== undefined) {
						nu.processType = pt;
					}
					return true;
				}
				case "fixedRotations": {
					const vertex = article.find(v => isCompatibleToNodeUserDataEntry("fixedRotations", v));
					assert(vertex !== undefined, "Expecting compatible node for fixedRotations");
					assertDebug(() => wsi4.node.workStepType(vertex) === "sheetCutting");
					const nu = findOrCreateNodeUpdate(vertex, "sheetCutting");
					assert(update.fixedRotations !== undefined);
					nu.nodeUserData = createUpdatedNodeUserDataImpl("fixedRotations", update.fixedRotations, nu.nodeUserData ?? wsi4.node.userData(vertex));
					return true;
				}
				case "sheetFilterSheetIds": {
					const vertex = article.find(v => isCompatibleToNodeUserDataEntry("sheetFilterSheetIds", v));
					assert(vertex !== undefined, "Expecting compatible node for sheetFilterSheetIds");
					assertDebug(() => wsi4.node.workStepType(vertex) === "sheetCutting");
					const nu = findOrCreateNodeUpdate(vertex, "sheetCutting");
					assert(update.sheetFilterSheetIds !== undefined);
					nu.nodeUserData = createUpdatedNodeUserDataImpl("sheetFilterSheetIds", update.sheetFilterSheetIds, nu.nodeUserData ?? wsi4.node.userData(vertex));
					return true;
				}
				case "dieChoiceMap": {
					const vertex = article.find(v => wsi4.node.workStepType(v) === "sheetBending");
					assert(vertex !== undefined, "Cannot set die choices for article without sheet bending node");
					const nu = findOrCreateNodeUpdate(vertex, "sheetBending");
					assert(update.dieChoiceMap !== undefined);
					assert(isNodeUpdateSheetBending(nu));
					nu.dieChoiceMap = update.dieChoiceMap;
					return true;
				}
			}
		});
	});

	// Update incompatible die choices in case a bend part's material is updated
	nodeUpdates.forEach(nu => {
		if (nu.type !== "sheetBending") {
			return;
		}

		assert(isNodeUpdateSheetBending(nu.content));
		if (nu.content.dieChoiceMap !== undefined) {
			return;
		}

		const sheetMaterialId = nodeUserDatumImpl("sheetMaterialId", nu.content.nodeUserData ?? {});
		if (sheetMaterialId === undefined) {
			return;
		}

		const vertex = nu.content.vertex;

		const currentDieChoiceMap = wsi4.node.dieChoiceMap(vertex) ?? [];
		const updatedDieChoiceMap = updateDieChoice(vertex, sheetMaterialId);
		assertDebug(() => updatedDieChoiceMap.length === currentDieChoiceMap.length, "Die choice maps inconsistent");
		if (currentDieChoiceMap.some(lhs => {
			const rhs = updatedDieChoiceMap.find(entry => entry.bendDescriptor === lhs.bendDescriptor);
			assert(rhs !== undefined, "Die choice maps inconsistent");
			return !isEqual(lhs, rhs);
		})) {
			nu.content.dieChoiceMap = updatedDieChoiceMap;
		}
	});

	// Each submitted set of compatible nodes will result in one or more new nestings.
	// Hence, submitting only relevant sets of compatible vertices.
	// A set of compatible vertices is considered relevant if any of its vertices is
	// 	a) Currently nested together with a node that is updated
	// 	b) Currently *not* nested together with a node that is updated
	// 	   but part of the set of (now) compatible vertices.
	const relevantTargetVertices = (() => {
		const result: Vertex[] = [];
		nodeUpdates.forEach(nodeUpdate => {
			switch (nodeUpdate.type) {
				case "sheetCutting":
				case "sheetBending": {
					const content = nodeUpdate.content;
					assert(isNodeUpdateSheetCutting(content) || isNodeUpdateSheetBending(content));
					const sheetVertex = wsi4.graph.reaching(content.vertex)
						.find(v => wsi4.node.workStepType(v) === "sheet");
					assert(sheetVertex !== undefined, "Expecting reaching sheet vertex");
					result.push(...wsi4.graph.reachable(sheetVertex).filter(v => wsi4.node.workStepType(v) === "sheetCutting"));
					break;
				}
				default: break;
			}
		});
		return result.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index);
	})();

	const sheetProcess = (() => {
		const processTable = getTable("process");
		return processTable.find(row => row.active && row.type === "sheet" && isAvailableProcess(row, processTable));
	})();

	const sheetNestingPrePartitions: SheetNestingPrePartition[] = [];
	sheetNestingPartitionsWithUpdates(updates)
		.filter(partition => partition.ids.some(lhs => relevantTargetVertices.some(rhs => isEqual(lhs, rhs))))
		.forEach(partition => {
			const input: SheetNestingPrePartition = {
				compatibleTargets: partition.ids.map(v => {
					const update = nodeUpdates.find(u => u.type === "sheetCutting" && isNodeUpdateSheetCutting(u.content) && isEqual(u.content.vertex, v));
					const fixedRotations = ((): number[] => {
						if (update === undefined) {
							return nodeUserDatumOrDefault("fixedRotations", v);
						}
						const content = update.content;
						assert(isNodeUpdateSheetCutting(content));
						return nodeUserDatumImpl("fixedRotations", content.nodeUserData ?? {}) ?? nodeUserDatumOrDefault("fixedRotations", v);
					})();
					const sheetFilterSheetIds = (() => {
						if (update === undefined) {
							return nodeUserDatumOrDefault("sheetFilterSheetIds", v);
						}
						const content = update.content;
						assert(isNodeUpdateSheetCutting(content));
						return nodeUserDatumImpl("sheetFilterSheetIds", content.nodeUserData ?? {}) ?? nodeUserDatumOrDefault("sheetFilterSheetIds", v);
					})();
					return {
						vertex: v,
						fixedRotations: fixedRotations,
						sheetFilterSheetIds: sheetFilterSheetIds,
					};
				}),
				nestingDistance: getSettingOrDefault("sheetNestingDistance"),
				nestorConfig: {
					timeout: getSharedDataEntry("nestorTimeLimit"),
					numRelevantNestings: 0,
				},
			};
			if (partition.sheetMaterialId !== undefined) {
				input.sheetMaterialId = partition.sheetMaterialId;
			}
			if (sheetProcess !== undefined) {
				input.sheetProcessId = sheetProcess.identifier;
			}
			sheetNestingPrePartitions.push(input);
		});

	wsi4.graphManipulator.applyUpdates(nodeUpdates, [], sheetNestingPrePartitions, []);

	populateSemimanufacturedArticleNames();

	checkGraphValidity();
}

/**
 * Common interface for updates of data that potentially require nesting recomputations
 *
 * When data of a sheet nesting's target nodes that potentially require a re-nesting
 * then the new nesting partitions are computed and applied (if there is a change).
 */
export interface TubePartUpdate {
	/**
	 * Any vertex of a sheet metal part article
	 */
	vertex: Vertex;
	tubeMaterialId?: string;
	tubeSpecificationId?: string;
	processId?: string;
}

/**
 * Update tube part related properties that might require a recomputation of underlying nestings.
 */
export function updateTubeParts(updates: readonly Readonly<TubePartUpdate>[]) {
	if (updates.length === 0) {
		// Nothing left to do
		return;
	}

	const nodeUpdates: NodeUpdate[] = [];

	const findOrCreateNodeUpdate = (vertex: Vertex): NodeUpdateTubeCutting => {
		const update = nodeUpdates.find(nu => "vertex" in nu.content && isEqual(nu.content.vertex, vertex));
		if (update !== undefined) {
			assert(isNodeUpdateSheetBending(update.content));
			return update.content;
		} else {
			const content: NodeUpdateSheetBending | NodeUpdateSheetCutting = {
				vertex: vertex,
			};
			nodeUpdates.push({
				type: "tubeCutting",
				content: content,
			});
			return content;
		}
	};

	updates.forEach(update => {
		type DataFields = Omit<TubePartUpdate, "vertex">;
		const keys = exhaustiveStringTuple<keyof DataFields>()(
			"tubeMaterialId",
			"tubeSpecificationId",
			"processId",
		);

		// Dummy return value ensures the switch statement is exhaustive
		keys.forEach((key): boolean => {
			if (update[key] === undefined) {
				return true;
			}

			const vertex = wsi4.graph.article(update.vertex).find(v => wsi4.node.workStepType(v) === "tubeCutting");
			assert(vertex !== undefined, "Expecting tubeCutting node in tube part article");

			const nu = findOrCreateNodeUpdate(vertex);
			switch (key) {
				case "tubeMaterialId": {
					assert(update.tubeMaterialId !== undefined);
					nu.nodeUserData = createUpdatedNodeUserDataImpl("tubeMaterialId", update.tubeMaterialId, nu.nodeUserData ?? wsi4.node.userData(vertex));
					return true;
				}
				case "tubeSpecificationId": {
					assert(update.tubeSpecificationId !== undefined);
					nu.nodeUserData = createUpdatedNodeUserDataImpl("tubeSpecificationId", update.tubeSpecificationId, nu.nodeUserData ?? wsi4.node.userData(vertex));
					return true;
				}
				case "processId": {
					assert(update.processId !== undefined);
					nu.processId = update.processId;
					return true;
				}
			}
		});
	});

	if (nodeUpdates.length === 0) {
		// Nothing left to do
		return;
	}

	const tubeProcess = (() => {
		const processTable = getTable("process");
		return processTable.find(row => row.active && row.type === "tube" && isAvailableProcess(row, processTable));
	})();

	// For each update of tubeMaterial and / or tubeSpecification the underlying tube semimanufactured node is also updated
	const tubeNestingPrePartitions: TubeNestingPrePartition[] = [];
	nodeUpdates.forEach(tubeCuttingNodeUpdate => {
		assertDebug(() => tubeCuttingNodeUpdate.type === "tubeCutting", "Expecting only tubeCutting updates at this point");
		assert(isNodeUpdateTubeCutting(tubeCuttingNodeUpdate.content), "Expecting only tubeCutting updates at this point");

		const ud = tubeCuttingNodeUpdate.content.nodeUserData;
		if (ud === undefined) {
			return;
		}

		const newTubeMaterialId = nodeUserDatumImpl("tubeMaterialId", ud);
		const newTubeSpecificationId = nodeUserDatumImpl("tubeSpecificationId", ud);
		if (newTubeMaterialId === undefined && newTubeSpecificationId === undefined) {
			return;
		}

		const oldTubeMaterialId = nodeUserDatum("tubeMaterialId", tubeCuttingNodeUpdate.content.vertex);
		const oldTubeSpecificationId = nodeUserDatum("tubeSpecificationId", tubeCuttingNodeUpdate.content.vertex);
		if (newTubeMaterialId !== undefined && newTubeMaterialId === oldTubeMaterialId && newTubeSpecificationId !== undefined && newTubeSpecificationId === oldTubeSpecificationId) {
			// Both material and specification are unchanged so skip recomputation of tube semimanufactured
			return;
		}

		const targetLength = (() => {
			const profileGeometry = wsi4.node.tubeProfileGeometry(tubeCuttingNodeUpdate.content.vertex);
			if (profileGeometry === undefined) {
				return 0;
			}

			const profile = tubeProfileForGeometry(profileGeometry);
			if (profile === undefined) {
				return 0;
			}

			const tubeMaterialId = newTubeMaterialId ?? oldTubeMaterialId;
			const tubeSpecificationId = newTubeSpecificationId ?? oldTubeSpecificationId;
			if (tubeMaterialId === undefined || tubeSpecificationId === undefined) {
				return 0;
			}

			const extrusionLength = wsi4.node.profileExtrusionLength(tubeCuttingNodeUpdate.content.vertex);
			const tube = getTable("tube")
				.filter(row => extrusionLength <= row.dimX
					&& row.tubeProfileId === profile.identifier
					&& row.tubeMaterialId === tubeMaterialId
					&& row.tubeSpecificationId === tubeSpecificationId)
				.sort((lhs, rhs) => rhs.dimX - lhs.dimX)[0];
			if (tube === undefined) {
				return 0;
			}

			return Math.max(0, tube.dimX - getSettingOrDefault("tubeClampingLength"));
		})();

		const prePartition: TubeNestingPrePartition = {
			targetVertex: tubeCuttingNodeUpdate.content.vertex,
			nestingDistance: getSettingOrDefault("tubeNestingDistance"),
			targetLength: targetLength,
		};

		if (tubeProcess !== undefined) {
			prePartition.tubeProcessId = tubeProcess.identifier;
		}

		tubeNestingPrePartitions.push(prePartition);
	});

	wsi4.graphManipulator.applyUpdates(nodeUpdates, [], [], tubeNestingPrePartitions);

	populateSemimanufacturedArticleNames();

	checkGraphValidity();
}
