import {
	checkGraphAxioms,
} from "qrc:/js/lib/axioms";
import {
	tolerances,
} from "qrc:/js/lib/constants";
import {
	CamCommandType,
	ProcessType,
	TableType,
	WidgetType,
	WorkStepType,
} from "qrc:/js/lib/generated/enum";
import {
	isLstEvaporateMode,
	isSheetCorner,
	isTrumpfLoadingSystem,
	isWidgetResultFormEditor,
	isWidgetResultAttachmentEditor,
	isWidgetResultBendingToolEditor,
	isWidgetResultJoiningSequenceEditor,
	isWidgetResultProcessSelector,
	isWidgetResultSheetFilterEditor,
	isWidgetResultSheetTappingEditor,
} from "qrc:/js/lib/generated/typeguard";
import {
	appendNode as appendNodeImpl,
	prependNode as prependNodeImpl,
	changeArticleUserData,
	changeBendCorrectionFlag as changeBendCorrectionFlagImpl,
	changeDieChoiceMap as changeDieChoiceMapImpl,
	toggleUpperSide as toggleUpperSideImpl,
	changeGraphUserData as changeGraphUserDataImpl,
	changeJoining as changeJoiningImpl,
	changeImportMultiplicities as changeImportMultiplicitiesImpl,
	changeProcessIds,
	changeNodesUserData as changeNodesUserDataImpl,
	changeNodeThickness as changeNodeThicknessImpl,
	changeNodeUserData,
	changeProjectName as changeProjectNameImpl,
	deleteArticles as deleteArticlesImpl,
	deleteNodes as deleteNodesImpl,
	setCommands as setCommandsImpl,
	SheetMetalPartUpdate,
	TubePartUpdate,
	updateSheetMetalParts,
	updateTubeParts,
} from "qrc:/js/lib/graph_manipulator";
import {
	computeAllowedSheets,
	getArticleSignatureVertex,
	getAssociatedSheetMaterialId,
	isComponentArticle,
	isJoiningArticle,
	isSheetArticle,
	rootIdKeysToVertices,
	rootIdKeyToVertex,
} from "qrc:/js/lib/graph_utils";
import {
	createCheckBoxRow,
	createDropDownRow,
	createLabelRow,
	createLineEditRow,
	createSpinBoxRow,
	createTextEditRow,
} from "qrc:/js/lib/gui_form_widget";
import {
	extractTableDropDownResult,
	getSaveFilePath,
	showAssemblyView,
	showCalcParamEditor,
	showError,
	showFaceColorEditor,
	showFormWidget,
	showInfo,
} from "qrc:/js/lib/gui_utils";
import {
	createJoining,
} from "qrc:/js/lib/joining_utils";
import {
	nodeLaserSheetCuttingGasId,
	problematicGeometryAsm,
	tubeProfileForVertex,
	computeTappingCandidates,
} from "qrc:/js/lib/node_utils";
import {
	computeVirtualSourcesGraphContext,
	computeVirtualTargetsGraphContext,
	GraphConstraintsContext,
} from "qrc:/js/lib/process";
import {
	computeValidLaserCuttingGasIds,
	getTable,
} from "qrc:/js/lib/table_utils";
import {
	getGraphUserDataEntry,
} from "qrc:/js/lib/userdata_config";
import {
	assert,
	assertDebug,
	bbDimensionX,
	bbDimensionY,
	computeArrayIntersection,
	exhaustiveStringTuple,
	isBoolean,
	isEqual,
	isNumber,
	isString,
	thicknessEquivalence,
} from "qrc:/js/lib/utils";
import {
	createUpdatedNodeUserData,
	isCompatibleToNodeUserDataEntry,
	nodeUserDatum,
	nodeUserDatumOrDefault,
} from "qrc:/js/lib/userdata_utils";
import {
	enforceSheetParts,
} from "qrc:/js/lib/graph_manipulator_utils";
import {
	getSettingOrDefault,
} from "qrc:/js/lib/settings_table";
import {
	sheetFilterSheetIds,
} from "qrc:/js/lib/sheet_util";

import {
	back,
} from "qrc:/js/lib/array_util";
import {
	BendLineEngravingMode,
	isBendLineEngravingMode,
} from "qrc:/js/lib/bend_line_engraving_mode";
import {
	articleUserDatum,
	createUpdatedArticleUserData,
} from "qrc:/js/lib/article_userdata_config";
import {
	sceneForVertex,
} from "qrc:/js/lib/scene_utils";
import {
	createLst,
	getLstCreationInfoInitialValues,
	getLstMaterial,
	getPossibleLstIdentifiers,
	LstCreationInfo,
	writeLstCreationInfoToSettings,
} from "./export_lst";
import {
	createProcessSelectorConfigForInjectedNode,
	createProcessSelectorConfigForVertex,
	dieSelectionWidgetInit,
	sequenceEditorInit,
} from "./gui_internal";

// Function that shows an assembly view containing all faces from undetectedFeatures ReplyStateIndicator
export function showGeometricProblems(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length === 1, "Expecting exactly one vertex");

	const vertex = vertices[0]!;
	assertDebug(() => wsi4.node.hasProblematicGeometries(vertex), "Expecting node to have problematic geometries");

	showAssemblyView(problematicGeometryAsm(vertex));
}

export function forceSheetNode(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	enforceSheetParts(vertices);
}

export function editProcess(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}

	const widgetConfig = createProcessSelectorConfigForVertex(vertices[0]!);
	const result = wsi4.ui.show(widgetConfig);

	if (result === undefined) {
		// User canceled
		return;
	}

	if (result.type !== WidgetType.processSelector) {
		return wsi4.throwError("Return value invalid");
	}

	const content = result.content;
	if (!isWidgetResultProcessSelector(content)) {
		return wsi4.throwError("Return value invalid");
	}

	// null indicates "automatic" WorkStepType detection
	const processId = content.forced ? content.processId : null;
	changeProcessIds(vertices.map(vertex => ({
		vertex: vertex,
		processId: processId,
	})));
}

function gatherValidSheets(vertex: Vertex, sheets: readonly Readonly<Sheet>[]): Readonly<Sheet>[] {
	const thickness = wsi4.node.sheetThickness(vertex);
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (thickness === undefined || twoDimRep === undefined) {
		return [];
	} else {
		// To reduce the number of errors resulting from an invalid material selection, a rough boundary check is performed.
		// This could be extended by e.g. taking fixed rotations into account.
		const bbox = wsi4.cam.util.boundingBox2(twoDimRep);
		const dimX = bbDimensionX(bbox);
		const dimY = bbDimensionY(bbox);
		return sheets.filter(sheet => Math.abs(sheet.thickness - thickness) < tolerances.thickness
			&& Math.max(dimX, dimY) < sheet.dimX
			&& Math.min(dimX, dimY) < sheet.dimY);
	}
}

interface MaterialAndGas {
	sheetMaterialId: string;
	laserSheetCuttingGasId: string;
}

function computeCommonMaterialGasCombinations(vertices: readonly Vertex[]): MaterialAndGas[] {
	const lscMaterials = getTable(TableType.sheetCuttingMaterialMapping);
	const lscCuttingSpeeds = getTable(TableType.laserSheetCuttingSpeed);
	const lscThicknessConstraints = getTable(TableType.laserSheetCuttingMaxThickness);

	const validSheets = (() => {
		const sheets = getTable(TableType.sheet);
		return vertices.map(vertex => gatherValidSheets(vertex, sheets));
	})();

	const allCombinations = (() => validSheets.map(sheets => sheets.reduce((acc: MaterialAndGas[], sheet) => {
		const lscMaterial = lscMaterials.find(row => row.sheetMaterialId === sheet.sheetMaterialId);
		assert(lscMaterial !== undefined, "Expecting valid laser sheet cutting material");

		const validGases = computeValidLaserCuttingGasIds(
			sheet.thickness,
			lscMaterial.sheetCuttingMaterialId,
			lscThicknessConstraints,
			lscCuttingSpeeds,
		);
		acc.push(...validGases.map(gasId => ({
			sheetMaterialId: sheet.sheetMaterialId,
			laserSheetCuttingGasId: gasId,
		})));
		return acc;
	}, [])))();

	const commonCombinations = computeArrayIntersection(allCombinations, isEqual);
	return commonCombinations.sort((lhs, rhs) => {
		if (lhs.sheetMaterialId < rhs.sheetMaterialId) {
			return -1;
		} else if (lhs.sheetMaterialId > rhs.sheetMaterialId) {
			return 1;
		} else if (lhs.laserSheetCuttingGasId < rhs.laserSheetCuttingGasId) {
			return -1;
		} else if (lhs.laserSheetCuttingGasId > rhs.laserSheetCuttingGasId) {
			return 1;
		} else {
			return 0;
		}
	});
}

export function editSheetMaterialAndCuttingGas(rootIdKeys: unknown): void {
	const inputVertices = rootIdKeysToVertices(rootIdKeys);
	if (inputVertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}

	const collectRelevantArticleVertices = (vertex: Vertex): Array<Vertex> => {
		const article = wsi4.graph.article(vertex);
		if (isJoiningArticle(article)) {
			return wsi4.graph.sources(article[0]!)
				.reduce((acc: Array<Vertex>, v) => [
					...acc,
					...collectRelevantArticleVertices(v),
				], []);
		} else if (isSheetArticle(article)) {
			return wsi4.graph.targets(article[article.length - 1]!)
				.reduce((acc: Array<Vertex>, v) => [
					...acc,
					...collectRelevantArticleVertices(v),
				], []);
		} else if (isComponentArticle(article)) {
			if (wsi4.graph.reaching(article[article.length - 1]!)
				.some(v => wsi4.node.workStepType(v) === WorkStepType.sheet)) {
				return article;
			} else {
				return [];
			}
		} else {
			return [];
		}
	};
	const materialVertices = inputVertices.reduce((acc: Array<Vertex>, v) => [
		...acc,
		...collectRelevantArticleVertices(v),
	], [])
		.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index)
		.filter(v => isCompatibleToNodeUserDataEntry("sheetMaterialId", v));

	if (materialVertices.length === 0) {
		return showInfo(wsi4.util.translate("no_valid_node_found_title"), wsi4.util.translate("no_valid_node_found_text."));
	}

	const commonMaterialGasCombinations = computeCommonMaterialGasCombinations(materialVertices);
	if (commonMaterialGasCombinations.length === 0) {
		return showInfo(
			wsi4.util.translate("selected_nodes_incompatible"),
			wsi4.util.translate("no_common_material_gas_combination_found"),
		);
	}

	const toId = (mag: Readonly<MaterialAndGas>): string => mag.sheetMaterialId + "_" + mag.laserSheetCuttingGasId;

	const dialogResultKey = "materialAndGas";
	const dialogResult = (() => {
		const sheetMaterials = getTable(TableType.sheetMaterial);
		const lscGases = getTable(TableType.laserSheetCuttingGas);
		const toName = (mag: Readonly<MaterialAndGas>): string => {
			const material = sheetMaterials.find(row => row.identifier === mag.sheetMaterialId);
			assert(material !== undefined, "Expecting valid sheet material");

			const lscGas = lscGases.find(row => row.identifier === mag.laserSheetCuttingGasId);
			assert(lscGas !== undefined, "Expecting valid laser sheet cutting gas");

			return material.name + " / " + lscGas.name;
		};

		const computeInitialValueIndex = (() => {
			const firstVertex = materialVertices[0]!;
			const firstVertexMaterialId = nodeUserDatum("sheetMaterialId", firstVertex);
			if (firstVertexMaterialId === undefined) {
				return -1;
			}

			const lscVertex = wsi4.graph.article(firstVertex)
				.find(vertex => wsi4.node.processType(vertex) === ProcessType.laserSheetCutting);
			assert(lscVertex !== undefined, "Expecting laser sheet cutting vertex");
			const firstVertexAssociatedGas = nodeUserDatum("laserSheetCuttingGasId", lscVertex);

			const foundIndex = commonMaterialGasCombinations.findIndex(
				materialAndGas => materialAndGas.sheetMaterialId === firstVertexMaterialId
					&& materialAndGas.laserSheetCuttingGasId === firstVertexAssociatedGas,
			);

			return Math.max(0, foundIndex);
		});

		return showFormWidget([
			createDropDownRow({
				key: dialogResultKey,
				name: `${wsi4.util.translate("Material")} / ${wsi4.util.translate("CuttingGas")}`,
				values: commonMaterialGasCombinations,
				toId: toId,
				toName: toName,
				computeInitialValueIndex: computeInitialValueIndex,
			}),
		]);
	})();

	if (dialogResult === undefined) {
		// User canceled
		return;
	}

	const [
		selectedMaterialId,
		selectedCuttingGasId,
	] = (() => {
		const selectedId = dialogResult.values[dialogResultKey];
		const mag = commonMaterialGasCombinations.find(mag => toId(mag) === selectedId);
		assert(mag !== undefined, "Expecting valid MaterialAndGas value");
		return [
			mag.sheetMaterialId,
			mag.laserSheetCuttingGasId,
		];
	})();

	const updates: SheetMetalPartUpdate[] = [];
	inputVertices.forEach(inputVertex => {
		const relevantArticleVertices = collectRelevantArticleVertices(inputVertex)
			.filter((lhs, index, self) => index === self.findIndex(rhs => isEqual(lhs, rhs)));

		const upsertEntry = (vertex: Vertex, applyValue: (u: SheetMetalPartUpdate) => void) => {
			const index = updates.findIndex(update => isEqual(update.vertex, vertex));
			if (index === -1) {
				const update: SheetMetalPartUpdate = {
					vertex: vertex,
				};
				applyValue(update);
				updates.push(update);
			} else {
				applyValue(updates[index]!);
			}
		};

		relevantArticleVertices
			.filter(vertex => isCompatibleToNodeUserDataEntry("sheetMaterialId", vertex))
			.forEach(vertex => upsertEntry(vertex, u => {
				u.sheetMaterialId = selectedMaterialId;
			}));

		relevantArticleVertices
			.filter(vertex => isCompatibleToNodeUserDataEntry("laserSheetCuttingGasId", vertex))
			.forEach(vertex => upsertEntry(vertex, u => {
				u.laserSheetCuttingGasId = selectedCuttingGasId;
			}));
	});
	console.time("Apply node updates");
	updateSheetMetalParts(updates);
	console.timeEnd("Apply node updates");

	checkGraphAxioms();
}

interface TubeCuttingParams {
	process: Process,
	material: TubeMaterial,
	specification: TubeSpecification,
}

function computeValidTubeCuttingParamCombinations(vertices: Vertex[]): TubeCuttingParams[] {
	const tubeTable = getTable(TableType.tube);
	const tubeProfileTable = getTable(TableType.tubeProfile);
	const tubeCuttingProcessMappingTable = getTable(TableType.tubeCuttingProcessMapping);
	const clampingLength = getSettingOrDefault("tubeClampingLength");

	type ParamIds = {
		processId: string,
		materialId: string,
		specificationId: string,
	};
	const validParamsPerVertex = vertices.map(vertex => {
		const profile = tubeProfileForVertex(vertex, tubeProfileTable);
		if (profile === undefined) {
			return [];
		}

		const minLength = clampingLength + wsi4.node.profileExtrusionLength(vertex);

		const validParams: ParamIds[] = [];
		tubeTable.filter(tube => tube.tubeProfileId === profile.identifier && tube.dimX >= minLength)
			.forEach(tube => {
				tubeCuttingProcessMappingTable.filter(mapping => mapping.tubeMaterialId === tube.tubeMaterialId)
					.forEach(mapping => validParams.push({
						processId: mapping.processId,
						materialId: tube.tubeMaterialId,
						specificationId: tube.tubeSpecificationId,
					}));
			});
		return validParams;
	});

	const uniqueParams = computeArrayIntersection(validParamsPerVertex, isEqual);

	const processTable = getTable(TableType.process);
	const tubeMaterialTable = getTable(TableType.tubeMaterial);
	const tubeSpecificationTable = getTable(TableType.tubeSpecification);

	return uniqueParams.map(ids => {
		const material = tubeMaterialTable.find(row => row.identifier === ids.materialId);
		assert(material !== undefined);
		const specification = tubeSpecificationTable.find(row => row.identifier === ids.specificationId);
		assert(specification !== undefined);
		const process = processTable.find(row => row.identifier === ids.processId);
		assert(process !== undefined);
		return {
			process: process,
			material: material,
			specification: specification,
		};
	})
		.sort((lhs, rhs) => {
			if (lhs.material.name !== rhs.material.name) {
				return (lhs.material.name < rhs.material.name) ? -1 : 1;
			} else if (lhs.specification.name !== rhs.specification.name) {
				return (lhs.specification.name < rhs.specification.name) ? -1 : 1;
			} else if (lhs.process.name !== rhs.process.name) {
				return (lhs.process.name < rhs.process.name) ? -1 : 1;
			} else {
				return 0;
			}
		});
}

export function editTubeProcessAndMaterial(rootIdKeys: unknown): void {
	const collectTubeCuttingVertices = () => {
		const inputVertices = rootIdKeysToVertices(rootIdKeys);
		const result: Vertex[] = [];
		inputVertices.forEach(inputVertex => {
			const article = wsi4.graph.article(inputVertex);
			const tubeCuttingVertices = [
				...article,
				...wsi4.graph.reaching(article[0]!),
			].filter(vertex => wsi4.node.workStepType(vertex) === WorkStepType.tubeCutting);
			if (tubeCuttingVertices !== undefined) {
				result.push(...tubeCuttingVertices);
			}
		});
		return result.filter((lhs, index, self) => index === self.findIndex(rhs => isEqual(lhs, rhs)));
	};

	const tubeCuttingVertices = collectTubeCuttingVertices();
	if (tubeCuttingVertices.length === 0) {
		return showInfo(
			wsi4.util.translate("no_valid_node_found_title"),
			wsi4.util.translate("no_valid_node_found_text."),
		);
	}

	const paramCombinations: readonly Readonly<TubeCuttingParams>[] = computeValidTubeCuttingParamCombinations(tubeCuttingVertices);
	if (paramCombinations.length === 0) {
		return showInfo(
			wsi4.util.translate("selected_nodes_incompatible"),
			wsi4.util.translate("no_common_parameter_combination_available"),
		);
	}

	const toId = (params: Readonly<TubeCuttingParams>): string => params.process.identifier + "_" + params.specification.identifier + "_" + params.material.identifier;
	const dialogResultKey = "params";
	const dialogResult = (() => {
		const toName = (params: Readonly<TubeCuttingParams>): string => params.process.name + " / " + params.specification.name + " / " + params.material.name;

		const computeInitialValueIndex = (() => {
			const firstVertex = tubeCuttingVertices[0]!;
			const firstVertexProcessId = wsi4.node.processId(firstVertex);
			const firstVertexMaterial = nodeUserDatum("tubeMaterialId", firstVertex);
			const firstVertexSpecification = nodeUserDatum("tubeSpecificationId", firstVertex);

			if (firstVertexMaterial === undefined || firstVertexSpecification === undefined) {
				return 0;
			}

			const foundIndex = paramCombinations.findIndex(
				params => params.process.identifier === firstVertexProcessId
					&& params.material.identifier === firstVertexMaterial
					&& params.specification.identifier === firstVertexSpecification,
			);

			return Math.max(0, foundIndex);
		});

		return showFormWidget([
			createDropDownRow({
				key: dialogResultKey,
				name: `${wsi4.util.translate("process")} / ${wsi4.util.translate("specification")} / ${wsi4.util.translate("material")}`,
				values: paramCombinations,
				toId: toId,
				toName: toName,
				computeInitialValueIndex: computeInitialValueIndex,
			}),
		]);
	})();

	if (dialogResult === undefined) {
		// User canceled
		return;
	}

	const selectedValueId = dialogResult.values[dialogResultKey];
	assert(isString(selectedValueId), "Dialog result invalid");

	const selectedParams = paramCombinations.find(params => toId(params) === selectedValueId);
	assert(selectedParams !== undefined);

	updateTubeParts(tubeCuttingVertices.map((vertex): TubePartUpdate => ({
		vertex: vertex,
		processId: selectedParams.process.identifier,
		tubeMaterialId: selectedParams.material.identifier,
		tubeSpecificationId: selectedParams.specification.identifier,
	})));
}

export function editBendingTools(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length !== 1) {
		return wsi4.throwError("Expecting exactly one vertex");
	}

	const vertex = vertices[0]!;
	const result = wsi4.ui.show({
		type: WidgetType.bendingToolEditor,
		content: dieSelectionWidgetInit(vertex),
	});

	if (result === undefined) {
		// User canceled
		return;
	}

	if (result.type !== WidgetType.bendingToolEditor) {
		return wsi4.throwError("Result invalid");
	}

	const content = result.content;
	if (!isWidgetResultBendingToolEditor(content)) {
		return wsi4.throwError("Result invalid");
	}
	changeDieChoiceMapImpl(vertex, content.dieChoiceMap);
	checkGraphAxioms();
}

/**
 * Setting sheet-filter of more than one sheet node at a time seems not to be working as expected.
 * The enabling-function (see gui_graph_widget_config) limits usage to single sheet vertices for now.
 */
export function editSheetFilter(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	assert(vertices.every(vertex => wsi4.node.processType(vertex) === ProcessType.sheet), "Expecting sheet vertices only");

	const currentSheetFilterIds = sheetFilterSheetIds(vertices[0]!) ?? [];

	// Sheet filter consists of sheet id(s).
	// In the sheet filter widget both sheet id and name should be shown so conversions are required before showing the dialog and when processing the result.
	const table = getTable(TableType.sheet);
	const idToNameIdString = (id: string) => {
		const sheet = table.find(row => row.identifier === id);
		if (sheet === undefined) {
			wsi4.util.error("editSheetFilter(): skipping invalid id: " + id);
			return undefined;
		} else {
			return `${sheet.name} (${sheet.identifier})`;
		}
	};
	const nameIdStringToId = (nameIdString: string) => {
		const sheet = table.find(row => idToNameIdString(row.identifier) === nameIdString);
		assert(sheet !== undefined, "Expecting valid sheet");
		return sheet.identifier;
	};

	const allowedSheets = computeArrayIntersection(vertices.map(vertex => computeAllowedSheets(vertex, table)));
	const result = wsi4.ui.show({
		type: WidgetType.sheetFilterEditor,
		content: {
			initialValue: {
				filter: {
					ids: currentSheetFilterIds.map(id => idToNameIdString(id))
						.filter((str): str is string => str !== undefined),
				},
			},
			sheets: allowedSheets.map(sheet => idToNameIdString(sheet.identifier))
				.filter((str): str is string => str !== undefined),
		},
	});

	if (result === undefined) {
		// User canceled
		return;
	}

	if (result.type !== WidgetType.sheetFilterEditor) {
		return wsi4.throwError("Return value invalid");
	}

	const content = result.content;
	if (!isWidgetResultSheetFilterEditor(content)) {
		return wsi4.throwError("Return value invalid");
	}

	const newSheetFilter = content.filter.ids.map(nameIdStringToId);

	const nodeUpdates: SheetMetalPartUpdate[] = [];
	vertices.forEach(sheetVertex => nodeUpdates.push(
		...wsi4.graph.reachable(sheetVertex)
			.filter(v => wsi4.node.workStepType(v) === "sheetCutting")
			.map((v): SheetMetalPartUpdate => ({
				vertex: v,
				sheetFilterSheetIds: newSheetFilter,
			})),
	));
	updateSheetMetalParts(nodeUpdates);
}

export function editTestReport(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	const currentValue = nodeUserDatumOrDefault("testReportRequired", vertices[0]!);
	if (currentValue === undefined) {
		return wsi4.throwError("Expecting valid value");
	}
	const dialogResult = showFormWidget([
		createCheckBoxRow(
			"enabled",
			wsi4.util.translate("test_report"),
			currentValue,
		),
	]);
	if (dialogResult === undefined) {
		// User cancleled
		return;
	}
	const enabled = dialogResult.values["enabled"];
	if (!isBoolean(enabled)) {
		return wsi4.throwError("Result invalid");
	}
	changeNodesUserDataImpl(vertices.map(vertex => ({
		vertex: vertex,
		userData: createUpdatedNodeUserData("testReportRequired", vertex, enabled),
	})));
	checkGraphAxioms();
}

export function editFlipSideUsage(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	toggleUpperSideImpl(vertices);
	checkGraphAxioms();
}
export function editBendCorrection(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	const currentValue = wsi4.node.bendCorrection(vertices[0]!);
	if (currentValue === undefined) {
		return wsi4.throwError("Expecting valid value");
	}
	const dialogResult = showFormWidget([
		createCheckBoxRow(
			"enabled",
			wsi4.util.translate("bend_correction"),
			currentValue,
		),
	]);
	if (dialogResult === undefined) {
		// User cancleled
		return;
	}
	const enabled = dialogResult.values["enabled"];
	if (!isBoolean(enabled)) {
		return wsi4.throwError("Result invalid");
	}
	changeBendCorrectionFlagImpl(vertices, enabled);
	checkGraphAxioms();
}

function createJoiningAssemblyMap(vertex: Vertex): AssemblyMapEntry[] {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.joining, "Pre-condition violated");
	const joining = wsi4.node.joining(vertex);
	if (joining === undefined) {
		return wsi4.throwError("collectJoiningAssemblies(): joining not available");
	}

	const assemblies: Array<Assembly> = [];
	for (const step of joining.joiningSteps) {
		for (const entry of step.entries) {
			assemblies.push(entry.assembly);
		}
	}
	return assemblies.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index)
		.map(assembly => ({
			id: wsi4.util.toKey(assembly),
			assembly: assembly,
		}));
}

export function editJoiningSequence(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length !== 1) {
		return wsi4.throwError("Expecting exactly one vertex");
	}

	const vertex = vertices[0]!;
	if (wsi4.node.workStepType(vertex) !== WorkStepType.joining) {
		return wsi4.throwError("Expecting joining vertex");
	}

	const legacyInitData = sequenceEditorInit(vertex);
	const widgetConfig = {
		type: WidgetType.joiningSequenceEditor,
		content: {
			initialValue: {
				joining: legacyInitData.joining,
			},
			articleName: legacyInitData.articleName,
			assemblyMap: createJoiningAssemblyMap(vertex),
		},
	};

	const result = wsi4.ui.show(widgetConfig);
	if (result === undefined) {
		// User cancleled
		return;
	}
	if (result.type !== WidgetType.joiningSequenceEditor || result.content === undefined || !isWidgetResultJoiningSequenceEditor(result.content)) {
		return wsi4.throwError("Return value invalid");
	}

	const joining = createJoining(vertex, result.content.joining);
	changeJoiningImpl(vertex, joining);
	checkGraphAxioms();
}

function applyNonConstFunction(vertices: Vertex[], func: (vertex: Vertex) => void): void {
	vertices.map(vertex => wsi4.node.rootId(vertex))
		.forEach((rootId): void => {
			const vertex = wsi4.node.vertexFromRootId(rootId);
			if (vertex === undefined) {
				return wsi4.throwError("Vertex invalid");
			}
			func(vertex);
		});
}

export function editMultiplicity(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}

	assert(
		vertices.every(vertex => wsi4.node.isImport(vertex)),
		"Expecting import vertices only",
	);

	const currentMult = wsi4.node.multiplicity(vertices[0]!);
	const result = showFormWidget(
		[
			createSpinBoxRow({
				initialValue: currentMult,
				min: 1,
				max: Number.MAX_SAFE_INTEGER,
				decimals: 0,
				key: "multiplicity",
				name: wsi4.util.translate("Multiplicity"),
			}),
		],
	);

	if (result === undefined) {
		// User canceled
		return;
	}

	const multiplicity = result.values["multiplicity"];
	if (!isNumber(multiplicity)) {
		return wsi4.throwError("Return value invalid");
	}
	changeImportMultiplicitiesImpl(vertices.map(vertex => ({
		vertex: vertex,
		multiplicity: multiplicity,
	})));
}

function requestProcessIdForUserDefinedNode(graphContext: Readonly<GraphConstraintsContext>): string|undefined {
	const widgetConfig = createProcessSelectorConfigForInjectedNode(graphContext);
	if (widgetConfig === undefined) {
		// No process available
		return undefined;
	}

	const result = wsi4.ui.show(widgetConfig);
	if (result === undefined) {
		// User canceled
		return undefined;
	}

	assert(isWidgetResultProcessSelector(result.content), "Dialog result invalid");

	if (!result.content.forced) {
		// As the associated node is expected to be of WorkStepType.userDefined, auto-detection does not make sense here.
		// This cannot be enforced via the widget as of now.
		return undefined;
	} else {
		return result.content.processId;
	}
}

/**
 * Prepend nodes to vertex keys
 *
 * Assumption:  Resulting new nodes will be of WorkStepType.userDefined
 */
export function prependNodes(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}

	const commonGraphContext = computeVirtualSourcesGraphContext(vertices);
	const processId = requestProcessIdForUserDefinedNode(commonGraphContext);
	if (processId === undefined) {
		return;
	}

	const process = getTable("process").find(row => row.identifier === processId);
	assert(process !== undefined);

	applyNonConstFunction(vertices, v => prependNodeImpl(v, process.identifier, process.type));
}

/**
 * Append nodes to vertex keys
 *
 * Assumption:  Resulting new nodes will be of WorkStepType.userDefined
 */
export function appendNodes(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}

	const commonGraphContext = computeVirtualTargetsGraphContext(vertices);
	const processId = requestProcessIdForUserDefinedNode(commonGraphContext);
	if (processId === undefined) {
		return;
	}

	const process = getTable("process").find(row => row.identifier === processId);
	assert(process !== undefined);

	applyNonConstFunction(vertices, v => appendNodeImpl(v, process.identifier, process.type));
}

export function deleteNodes(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex.  Got " + JSON.stringify(rootIdKeys));
	}
	deleteNodesImpl(vertices);
	checkGraphAxioms();
}

export function deleteArticles(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex.  Got " + JSON.stringify(rootIdKeys));
	}
	deleteArticlesImpl(vertices);
	checkGraphAxioms();
}

export function editLaserSheetCuttingGas(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	if (vertices.some(vertex => !isCompatibleToNodeUserDataEntry("laserSheetCuttingGasId", vertex))) {
		return wsi4.throwError("At least one associated node is incompatible to \"laserSheetCuttingGasId\"");
	}

	const rowToId = (row: Readonly<LaserSheetCuttingGas>) => row.identifier;
	const rowToText = (row: Readonly<LaserSheetCuttingGas>) => row.name;
	const computeInitialIndex = (table: readonly Readonly<LaserSheetCuttingGas>[]) => {
		const laserSheetCuttingGas = nodeUserDatum("laserSheetCuttingGasId", vertices[0]!);
		return Math.max(0, table.findIndex(row => (laserSheetCuttingGas === undefined ? true : row.identifier === laserSheetCuttingGas)));
	};

	const table = getTable(TableType.laserSheetCuttingGas);

	const formWidgetResultKey = "gas";
	const widgetConfig = createDropDownRow({
		values: table,
		key: formWidgetResultKey,
		name: wsi4.util.translate("CuttingGas"),
		toId: rowToId,
		toName: rowToText,
		computeInitialValueIndex: computeInitialIndex,
	});
	const result = wsi4.ui.show({
		type: WidgetType.formEditor,
		content: {
			rows: [ widgetConfig ],
		},
	});

	if (result === undefined) {
		// User canceled
		return;
	}
	if (result.type !== WidgetType.formEditor) {
		return wsi4.throwError("Return value invalid");
	}

	const selectedRow = (() => {
		assert(isWidgetResultFormEditor(result.content));
		const entryId = result.content.values[formWidgetResultKey];
		assert(isString(entryId), "Expecting valid string");
		return extractTableDropDownResult<"laserSheetCuttingGas">(table, rowToId, entryId);
	})();

	if (selectedRow === undefined) {
		return wsi4.throwError("Selection invalid");
	}

	changeNodesUserDataImpl(vertices.map(vertex => ({
		vertex: vertex,
		userData: createUpdatedNodeUserData("laserSheetCuttingGasId", vertex, selectedRow.identifier),
	})));
	checkGraphAxioms();
}

export function editComment(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting at least one vertex");
	}
	if (vertices.some(vertex => !isCompatibleToNodeUserDataEntry("comment", vertex))) {
		return wsi4.throwError("Expecting valid vertices only");
	}

	const initialValue = (() => {
		const comment = nodeUserDatum("comment", vertices[0]!);
		return comment === undefined ? "" : comment;
	})();

	const result = showFormWidget([
		createTextEditRow(
			"comment",
			wsi4.util.translate("Comment"),
			initialValue,
		),
	]);

	if (result === undefined) {
		// User canceled
		return;
	}

	const comment = result.values["comment"];
	if (!isString(comment)) {
		return wsi4.throwError("Return value invalid");
	}

	changeNodesUserDataImpl(vertices.map(vertex => ({
		vertex: vertex,
		userData: createUpdatedNodeUserData("comment", vertex, comment),
	})));
	checkGraphAxioms();
}

export function editCalcParams(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length > 0, "Expecting at least one vertex");

	const initialValue = ((): WidgetResultCalcParamEditor => {
		const vertex = vertices[0]!;
		const userData = wsi4.node.userData(vertex);
		const o : WidgetResultCalcParamEditor = {
			userDefinedScalePrices: nodeUserDatumOrDefault("userDefinedScalePrices", vertex, userData),
		};
		{
			const mcpp = nodeUserDatum("userDefinedMaterialCostsPerPiece", vertex, userData);
			if (mcpp !== undefined) {
				o.materialCostsPerPiece = mcpp;
			}
		}
		{
			const st = nodeUserDatum("userDefinedSetupTime", vertex, userData);
			if (st !== undefined) {
				o.setupTime = st;
			}
		}
		{
			const utpp = nodeUserDatum("userDefinedUnitTimePerPiece", vertex, userData);
			if (utpp !== undefined) {
				o.unitTimePerPiece = utpp;
			}
		}
		return o;
	})();

	const result = showCalcParamEditor(initialValue);
	if (result === undefined) {
		// User canceled
		return;
	} else {
		changeNodesUserDataImpl(vertices.map(vertex => {
			let userData = wsi4.node.userData(vertex);
			userData = createUpdatedNodeUserData("userDefinedMaterialCostsPerPiece", vertex, result.materialCostsPerPiece, userData);
			userData = createUpdatedNodeUserData("userDefinedSetupTime", vertex, result.setupTime, userData);
			userData = createUpdatedNodeUserData("userDefinedUnitTimePerPiece", vertex, result.unitTimePerPiece, userData);
			userData = createUpdatedNodeUserData("userDefinedScalePrices", vertex, result.userDefinedScalePrices, userData);
			return {
				vertex: vertex,
				userData: userData,
			};
		}));
	}
}

function editAttachments(getAttachments: () => Attachment[], processAttachments: (attachments: Attachment[]) => void): void {
	const result = wsi4.ui.show({
		type: WidgetType.attachmentEditor,
		content: {
			initialValue: {
				attachments: getAttachments(),
			},
		},
	});

	if (result === undefined) {
		// User canceled
		return;
	}

	if (result.type !== WidgetType.attachmentEditor) {
		return wsi4.throwError("Result invalid");
	}

	const content = result.content;
	if (!isWidgetResultAttachmentEditor(content)) {
		return wsi4.throwError("Result invalid");
	}
	processAttachments(content.attachments);
}

export function editNodeAttachments(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length !== 1) {
		return wsi4.throwError("Expecting exactly one vertex");
	}

	const vertex = vertices[0]!;
	if (!isCompatibleToNodeUserDataEntry("attachments", vertex)) {
		return wsi4.throwError("Expecting associated node to be compatible to \"attachments\"");
	}

	const getAttachments = () => {
		const attachments = nodeUserDatum("attachments", vertex);
		return attachments === undefined ? [] : attachments;
	};
	const processAttachments = (attachments: Attachment[]) => changeNodesUserDataImpl([
		{
			vertex: vertex,
			userData: createUpdatedNodeUserData("attachments", vertex, attachments),
		},
	]);
	checkGraphAxioms();
	editAttachments(getAttachments, processAttachments);
}

export function editGraphAttachments(): void {
	const getAttachments = () => {
		const attachments = getGraphUserDataEntry("attachments");
		return attachments === undefined ? [] : attachments;
	};
	const processAttachments = (attachments: Attachment[]) => changeGraphUserDataImpl({
		...wsi4.graph.userData(),
		attachments: attachments,
	});
	checkGraphAxioms();
	editAttachments(getAttachments, processAttachments);
}

export function editProjectName(): void {
	const currentName = getGraphUserDataEntry("name");
	const dialogResult = showFormWidget([
		createLineEditRow(
			"name",
			wsi4.util.translate("Name"),
			currentName === undefined ? "" : currentName,
		),
	]);
	if (dialogResult === undefined) {
		return;
	}
	changeProjectNameImpl(isString(dialogResult.values["name"]) ? dialogResult.values["name"] : wsi4.throwError("Result invalid"));
}

// Using drop-down to show available options
// This type combines a text-label with each possible value for rotations
interface RotationsWithName {
	name: string;
	value: number[];
}

const rotationsWithName: RotationsWithName[] = [
	{
		value: [],
		name: wsi4.util.translate("free"),
	},
	{
		value: [
			0,
			180,
		],
		name: wsi4.util.translate("fixed_to_0_180_deg"),
	},
	{
		value: [
			90,
			270,
		],
		name: wsi4.util.translate("fixed_to_90_270_deg"),
	},
];

function getRotsWithName(unsortedRots: number[]): RotationsWithName|undefined {
// Note: this is *not* the same as sort's default implementation as by default *string*-values are compared
	const sortedRots = Array.from(unsortedRots)
		.sort((lhs, rhs) => lhs - rhs);
	const eps = 0.0001;
	return rotationsWithName.find(tdrr => tdrr.value.length === sortedRots.length && tdrr.value.every((rot, index) => Math.abs(rot - sortedRots[index]!) < eps));
}

function getRotsWithNameOrThrow(unsortedRots: number[]): RotationsWithName {
	const result = getRotsWithName(unsortedRots);
	return result === undefined ? wsi4.throwError("Result invalid") : result;
}

function isMatchingRotation(unsortedRots: number[]): boolean {
	return getRotsWithName(unsortedRots) !== undefined;
}

export function editFixedRotations(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting exactly one vertex");
	}
	if (vertices.some(vertex => !isCompatibleToNodeUserDataEntry("fixedRotations", vertex))) {
		return wsi4.throwError("Expecting all nodes to be compatible");
	}
	const userDataRotations = nodeUserDatumOrDefault("fixedRotations", vertices[0]!);
	const initRotLabelPair = isMatchingRotation(userDataRotations) ? getRotsWithNameOrThrow(userDataRotations) : getRotsWithNameOrThrow([]);
	const dialogResult = showFormWidget([
		createDropDownRow({
			name: wsi4.util.translate("rotation"),
			key: "rotationLabel",
			values: rotationsWithName,
			toId: (arg: Readonly<RotationsWithName>) : string => arg.name,
			toName: (arg: Readonly<RotationsWithName>) : string => arg.name,
			computeInitialValueIndex: (values: readonly Readonly<RotationsWithName>[]) : number => Math.max(0, values.findIndex(arg => arg.name === initRotLabelPair.name)),
		}),
	]);
	if (dialogResult === undefined) {
		// User cancleled
		return;
	}
	const name = dialogResult.values["rotationLabel"];
	if (!isString(name)) {
		return wsi4.throwError("Result invalid");
	}
	const resultingRotations = rotationsWithName.find(tdrr => tdrr.name === name);
	if (resultingRotations === undefined) {
		return wsi4.throwError("Result invalid");
	}
	updateSheetMetalParts(vertices.map(v => ({
		vertex: v,
		fixedRotations: resultingRotations.value,
	})));
	checkGraphAxioms();
}

export function editDeburringParameters(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length === 0) {
		return wsi4.throwError("Expecting exactly one vertex");
	}
	if (vertices.some(vertex => !isCompatibleToNodeUserDataEntry("deburrDoubleSided", vertex))) {
		return wsi4.throwError("Expecting all nodes to be compatible");
	}
	const initialValue = nodeUserDatumOrDefault("deburrDoubleSided", vertices[0]!) ?? false;
	const dialogResult = showFormWidget([
		createCheckBoxRow(
			"deburrDoubleSided",
			wsi4.util.translate("double_sided"),
			initialValue,
		),
	]);
	if (dialogResult === undefined) {
		// User canceled
		return;
	}
	const deburrDoubleSided = dialogResult.values["deburrDoubleSided"];
	if (!isBoolean(deburrDoubleSided)) {
		return wsi4.throwError("Result invalid");
	}
	changeNodesUserDataImpl(vertices.map(vertex => ({
		vertex: vertex,
		userData: createUpdatedNodeUserData("deburrDoubleSided", vertex, deburrDoubleSided),
	})));
	checkGraphAxioms();
}

export function dumpIop(rootIdKeys: unknown): void {
	if (!wsi4.util.isDebug()) {
		return wsi4.throwError("One calling in debug mode");
	}
	const vertices = rootIdKeysToVertices(rootIdKeys);
	if (vertices.length !== 1) {
		return wsi4.throwError("Expecting exactly one vertex");
	}
	const vertex = vertices[0]!;
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return wsi4.throwError("Expecting twoDimRep");
	}
	const iops = wsi4.cam.util.extractInnerOuterPolygons(twoDimRep);
	if (iops.length !== 1) {
		return wsi4.throwError("Expecting exactly one iop");
	}
	wsi4.geo.util.dump(iops[0]!);
}

function toHexString(arg: number): string {
	assert(arg <= 255, "Invalid argument");
	const result = Number.parseInt(arg.toFixed(0))
		.toString(16);
	return result.length === 1 ? "0" + result : result;
}

function vectorToHexString(vector: Vector3): string {
	const entries = vector.entries;
	return "#" + toHexString(255 * entries[0]) + toHexString(255 * entries[1]) + toHexString(255 * entries[2]);
}

function createWidgetResultGeometryColorSelector(commands: CamCommand[]): WidgetResultGeometryColorSelector {
	return {
		selection: commands.filter(command => command.type === CamCommandType.setColor)
			.map(command => ({
				entities: command.content.entities,
				data: {color: vectorToHexString(command.content.color)},
			})),
	};
}

function hexStringToVector(rgb: string): Vector3 {
	assert(rgb.length === 7);
	const r = Number.parseInt(rgb.substring(1, 3), 16);
	const g = Number.parseInt(rgb.substring(3, 5), 16);
	const b = Number.parseInt(rgb.substring(5, 7), 16);
	assert(r < 256);
	assert(b < 256);
	assert(g < 256);
	return {entries: [
		r / 255,
		g / 255,
		b / 255,
	]};
}

function createSetColorCommands(widgetResult: WidgetResultGeometryColorSelector): CamCommandSetColor[] {
	return widgetResult.selection.map(entry => {
		const rgbString = entry.data["color"];
		assert(isString(rgbString));
		return {
			entities: entry.entities,
			color: hexStringToVector(rgbString),
		};
	});
}

/*
* Note: Face color editor requires the underlying assembly tree to be in "neutral" state relative to the commands
* that correspond to the submitted initial selection.
*
* As a consequence the graph needs to be modified to undo the currently applied commands.
*/
export function editFaceColors(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length === 1, "Expecting exactly one vertex");
	assert(wsi4.node.processType(vertices[0]!) === ProcessType.powderCoating);

	const rootId = wsi4.node.rootId(vertices[0]!);
	const getVertex = () => {
		const v = wsi4.node.vertexFromRootId(rootId);
		assert(v !== undefined, "Expecting valid vertex");
		return v;
	};

	const assembly = wsi4.node.assembly(getVertex());
	assert(assembly !== undefined, "Expecting valid assembly");

	const currentCommands = wsi4.node.commands(getVertex());
	setCommandsImpl(getVertex(), []);
	const dialogResult = showFaceColorEditor(assembly, createWidgetResultGeometryColorSelector(currentCommands));
	if (dialogResult === undefined) {
		setCommandsImpl(getVertex(), currentCommands);
	} else {
		setCommandsImpl(getVertex(), createSetColorCommands(dialogResult)
			.map(command => ({
				type: CamCommandType.setColor,
				content: command,
			})));
	}
	checkGraphAxioms();
}

export function editNumThreads(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length > 0);
	assert(vertices.every(v => wsi4.node.processType(v) === ProcessType.userDefinedThreading));
	const dialogResult = showFormWidget([
		createSpinBoxRow({
			name: wsi4.util.translate("num_threads"),
			key: "num",
			initialValue: nodeUserDatumOrDefault("numThreads", vertices[0]!),
			min: 0,
			max: Number.MAX_SAFE_INTEGER,
			decimals: 0,
		}),
	]);
	if (dialogResult === undefined) {
		// User canceled
		return;
	}
	const numThreads = dialogResult.values["num"];
	assert(isNumber(numThreads));
	changeNodesUserDataImpl(vertices.map(vertex => ({
		vertex: vertex,
		userData: createUpdatedNodeUserData("numThreads", vertex, numThreads),
	})));
	checkGraphAxioms();
}

export function editNumCountersinks(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length > 0);
	assert(vertices.every(v => wsi4.node.processType(v) === ProcessType.userDefinedCountersinking));
	const dialogResult = showFormWidget([
		createSpinBoxRow({
			name: wsi4.util.translate("num_countersinks"),
			key: "num",
			initialValue: nodeUserDatumOrDefault("numCountersinks", vertices[0]!),
			min: 0,
			max: Number.MAX_SAFE_INTEGER,
			decimals: 0,
		}),
	]);
	if (dialogResult === undefined) {
		// User canceled
		return;
	}
	const numCountersinks = dialogResult.values["num"];
	assert(isNumber(numCountersinks));
	changeNodesUserDataImpl(vertices.map(vertex => ({
		vertex: vertex,
		userData: createUpdatedNodeUserData("numCountersinks", vertex, numCountersinks),
	})));
	checkGraphAxioms();
}

export function editThickness(rootIdKeys: unknown): void {
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length > 0);

	const currentThickness = wsi4.node.sheetThickness(vertices[0]!);
	assert(currentThickness !== undefined);
	const toId = (row: Readonly<Sheet>) => row.identifier;
	const toString = (row: Readonly<Sheet>) => row.thickness.toFixed(2);
	const computeIndex = (table: readonly Readonly<Sheet>[]) => table.findIndex(row => row.thickness === currentThickness);
	const materials = vertices.map(v => getAssociatedSheetMaterialId(v));
	const table = getTable(TableType.sheet);
	const allowedThicknesses = table.filter(s => materials.some(m => m === s.sheetMaterialId)) //
		.filter((lhs, index, self) => self.findIndex(rhs => thicknessEquivalence(lhs.thickness, rhs.thickness)) === index);

	const formWidgetResultKey = "thickness";
	const result = showFormWidget([
		createDropDownRow({
			values: allowedThicknesses,
			key: formWidgetResultKey,
			name: wsi4.util.translate("SheetThickness"),
			toId: toId,
			toName: toString,
			computeInitialValueIndex: computeIndex,
		}),
	]);
	if (result === undefined) {
		// User canceled
		return;
	}

	const selectedRow = (() => {
		const entryId = result.values[formWidgetResultKey];
		assert(isString(entryId), "Expecting valid string");

		return extractTableDropDownResult<"sheet">(table, toId, entryId);
	})();

	applyNonConstFunction(vertices, vertex => changeNodeThicknessImpl(vertex, selectedRow.thickness));
	checkGraphAxioms();
}

export function editSheetTappingParams(rootIdKeys: unknown): void {
	const vertex = (() => {
		const vertices = rootIdKeysToVertices(rootIdKeys);
		assert(vertices.length === 1, "Expecting exactly one vertex");
		return vertices[0]!;
	})();
	assert(wsi4.node.processType(vertex) === ProcessType.sheetTapping, "Expecting sheet tapping vertex");

	const base64Scene = (() => {
		const scene = sceneForVertex(vertex, { sheetTappingLabelMode: "indices" });
		const binaryBuffer = wsi4.geo.util.serializeScene(scene);
		const base64Buffer = wsi4.util.toBase64(binaryBuffer);
		return wsi4.util.arrayBufferToString(base64Buffer);
	})();

	const screwThreads = getTable(TableType.screwThread);

	const twoDimRepTransformation = wsi4.node.twoDimRepTransformation(vertex);
	assert(twoDimRepTransformation !== undefined, "Expecting valid two dim rep transformation");

	const tappingCandidates = computeTappingCandidates(vertex);

	const widgetConfigCandidates: SheetTappingEditorCandidate[] = tappingCandidates.map((candidate, index) => ({
		id: "candidate" + index.toFixed(0),
		name: "#" + index.toFixed(0),
		values: candidate.matchingScrewThreads.map(mst => ({
			id: mst.identifier,
			name: mst.name,
		})),
	}));

	const initialValue = (() => {
		const sheetTappingData = nodeUserDatumOrDefault("sheetTappingData", vertex);
		const selection = sheetTappingData
			.map((sheetTappingDataEntry): SheetTappingEditorSelectionEntry | undefined => {
				const screwThread = screwThreads.find(row => row.identifier === sheetTappingDataEntry.screwThread.identifier);
				if (screwThread === undefined) {
					wsi4.util.error("editSheetTappingParams():  Missing screw thread table entry for id " + sheetTappingDataEntry.screwThread.identifier);
					return undefined;
				}

				const lhs = sheetTappingDataEntry.cadFeature;
				const index = tappingCandidates.findIndex(candidate => {
					const rhs = candidate.cadFeature;
					return lhs.type === rhs.type && lhs.content.featureDescriptor === rhs.content.featureDescriptor;
				});

				if (index === -1) {
					wsi4.util.error("editSheetTappingParams():  Missing tapping candidate");
					return undefined;
				}

				const configCandidate = widgetConfigCandidates[index]!;
				const candidateId = configCandidate.id;
				if (configCandidate.values.some(value => value.id === sheetTappingDataEntry.screwThread.identifier)) {
					return {
						candidateId: candidateId,
						valueId: sheetTappingDataEntry.screwThread.identifier,
					};
				} else {
					wsi4.util.error("editSheetTappingParams():  Missing tapping candidate for screw thread id " + sheetTappingDataEntry.screwThread.identifier);
					return undefined;
				}
			})
			.filter((arg): arg is SheetTappingEditorSelectionEntry => arg !== undefined);
		return {
			selection: selection,
		};
	})();

	const config: WidgetConfigSheetTappingEditor = {
		base64Scene: base64Scene,
		candidates: widgetConfigCandidates,
		initialValue: initialValue,
	};

	const dialogResult = wsi4.ui.show({
		type: WidgetType.sheetTappingEditor,
		content: config,
	});

	if (dialogResult === undefined) {
		// User canceled
		return;
	}

	assert(isWidgetResultSheetTappingEditor(dialogResult.content), "Dialog result invalid");

	const resultingSheetTappingData = dialogResult.content.selection.map(selectionEntry => {
		const candidateIndex = widgetConfigCandidates.findIndex(candidate => candidate.id === selectionEntry.candidateId);
		assert(candidateIndex !== -1, "Expecting valid candidate index");

		const tappingCandidate = tappingCandidates[candidateIndex]!;

		const screwThread = tappingCandidate.matchingScrewThreads.find(screwThread => screwThread.identifier === selectionEntry.valueId);
		assert(screwThread !== undefined, "Expecting valid screw thread");
		return {
			cadFeature: tappingCandidate.cadFeature,
			screwThread: screwThread,
		};
	});

	const updatedUserData = createUpdatedNodeUserData("sheetTappingData", vertex, resultingSheetTappingData);
	changeNodeUserData(vertex, updatedUserData);
}

export function editArticleComment(signatureNodeRootIdKey: unknown): void {
	const vertex = rootIdKeyToVertex(signatureNodeRootIdKey);
	assertDebug(
		() => wsi4.graph.articles()
			.some(article => isEqual(getArticleSignatureVertex(article), vertex)),
		"Submitted root id is not associated with any article's signature vertex.",
	);

	const currentComment = articleUserDatum("comment", vertex) ?? "";

	const key = "comment";
	const result = showFormWidget([
		createTextEditRow(
			key,
			wsi4.util.translate("Comment"),
			currentComment,
		),
	]);

	if (result === undefined) {
		// User canceled
		return;
	}

	const newComment = result.values[key];
	assert(isString(newComment));

	if (newComment === currentComment) {
		return;
	}

	changeArticleUserData([
		{
			vertex: vertex,
			userData: createUpdatedArticleUserData("comment", newComment, wsi4.node.articleUserData(vertex)),
		},
	]);
}

export function editArticleMultiplicity(signatureNodeRootIdKey: unknown): void {
	assertDebug(
		() => {
			const vertex = rootIdKeyToVertex(signatureNodeRootIdKey);
			return wsi4.graph.articles()
				.some(article => isEqual(getArticleSignatureVertex(article), vertex));
		},
		"Submitted root id is not associated with any article's signature vertex.",
	);
	editMultiplicity([ signatureNodeRootIdKey ]);
}

export function deleteArticle(signatureNodeRootIdKey: unknown): void {
	const article = (() => {
		const vertex = rootIdKeyToVertex(signatureNodeRootIdKey);
		return wsi4.graph.articles()
			.find(article => isEqual(getArticleSignatureVertex(article), vertex));
	})();
	assert(article !== undefined, "Submitted root id is not associated with any article's signature vertex.");

	// The current implementation of node deletion requires certain nodes to be deleted first before deleteting signature nodes.
	deleteNodesImpl(article.filter(vertex => {
		const wst = wsi4.node.workStepType(vertex);
		return wst === WorkStepType.userDefined || wst === WorkStepType.transform;
	}));

	// Deleting the signature node is expected to result in the entire article to be removed.
	deleteNodesImpl([ rootIdKeyToVertex(signatureNodeRootIdKey) ]);
}

/**
 * Exporting lst of more than one sheet node does not work anymore
 */
export function exportLst(rootIdKeys: unknown): void {
	if (!wsi4.isFeatureEnabled("lstExport")) {
		showError(wsi4.util.translate("export_lst"), wsi4.util.translate("lst_export_is_liable_to_pay"));
		return;
	}
	const vertices = rootIdKeysToVertices(rootIdKeys);
	assert(vertices.length === 1, "Expecting exactly one vertex");
	const sheetVertex = vertices[0]!;
	assert(wsi4.node.processType(sheetVertex) === ProcessType.sheet, "Expecting sheet vertices only");

	const material = getLstMaterial(sheetVertex);
	const sheetThickness = wsi4.node.sheetThickness(sheetVertex);
	assert(sheetThickness !== undefined, "Missing sheetThickness");
	const targets = wsi4.graph.targets(sheetVertex);
	assert(targets.length > 0, "Missing targets");
	const gas = nodeLaserSheetCuttingGasId(targets[0]!);
	assert(gas !== undefined, "Missing gas");
	const lttIdentifiers = getPossibleLstIdentifiers(sheetThickness, material, gas);

	if (lttIdentifiers.length === 0) {
		showError(wsi4.util.translate("export_lst"), wsi4.util.translate("no_ltt_for_configuration"));
		return;
	}

	const path = getSaveFilePath(wsi4.util.translate("export_lst"));
	if (path === undefined) {
		// user canceled
		return;
	}

	const toSymbol = (laserPowerWarning: boolean) => laserPowerWarning ? "*" : "";

	const initialValues = getLstCreationInfoInitialValues();

	const keys: {[index in keyof LstCreationInfo] : string} = {
		lttIdentifier: "lttIdentifier",
		loadingSystem: "loadingSystem",
		measureSheet: "measureSheet",
		evaporate: "evaporate",
		measuringCorner: "measuringCorner",
		sheetMetalStop: "sheetMetalStop",
	};

	const loadingSystems = exhaustiveStringTuple<TrumpfLoadingSystem>()("manual", "paletteChange", "paletteChangeLiftMaster", "paletteChangeLiftMasterSort", "paletteChangeLiftMasterSortPullingDevice", "onePalette");
	const evaporateModes = exhaustiveStringTuple<LstEvaporateMode>()("none", "beforeEachPart", "early");

	const corners = exhaustiveStringTuple<SheetCorner>()("lowerLeft", "lowerRight", "upperLeft", "upperRight");

	const widgetConfig = [
		createDropDownRow({
			key: keys["lttIdentifier"],
			name: `${wsi4.util.translate("lttIdentifier")}`,
			values: lttIdentifiers.map(id => id.id + toSymbol(id.laserPowerWarning)),
			toId: (lttId: string) => lttId,
			toName: (lttId: string) => lttId,
			computeInitialValueIndex: () => {
				const index = lttIdentifiers.findIndex(s => initialValues.lttIdentifier === s.id);
				return Math.max(index, 0);
			},
		}),
		createCheckBoxRow(keys["measureSheet"],
			`${wsi4.util.translate("measureSheet")}`,
			initialValues.measureSheet),
		createDropDownRow({
			key: keys["loadingSystem"],
			name: `${wsi4.util.translate("loadingSystem")}`,
			values: loadingSystems,
			toId: (id: string) => id,
			toName: (id: string) => wsi4.util.translate(id),
			computeInitialValueIndex: () => {
				const index = loadingSystems.findIndex(s => initialValues.loadingSystem === s);
				return index < 0 ? 1 : index;
			},
		}),
		createDropDownRow({
			key: keys["evaporate"],
			name: `${wsi4.util.translate("evaporate")}`,
			values: evaporateModes,
			toId: (id: string) => id,
			toName: (id: string) => wsi4.util.translate("evaporate_" + id),
			computeInitialValueIndex: () => {
				const index = evaporateModes.findIndex(s => initialValues.evaporate === s);
				return Math.max(index, 0);
			}}),
		createDropDownRow({
			key: keys["measuringCorner"],
			name: `${wsi4.util.translate("measuringCorner")}`,
			values: corners,
			toId: (id: string) => id,
			toName: (id: string) => wsi4.util.translate(id),
			computeInitialValueIndex: () => {
				const index = corners.findIndex(s => initialValues.measuringCorner === s);
				return index < 0 ? 1 : index;
			},
		}),
		createDropDownRow({
			key: keys["sheetMetalStop"],
			name: `${wsi4.util.translate("sheetMetalStop")}`,
			values: corners,
			toId: (id: string) => id,
			toName: (id: string) => wsi4.util.translate(id),
			computeInitialValueIndex: () => {
				const index = corners.findIndex(s => initialValues.sheetMetalStop === s);
				return index < 0 ? 1 : index;
			},
		}),
	];
	if (lttIdentifiers.some(id => id.laserPowerWarning)) {
		widgetConfig.push(createLabelRow("labelRow", toSymbol(true) + " " + wsi4.util.translate("not_for_all_laserpowers"), ""));
	}
	const lttResult = showFormWidget(widgetConfig);

	if (lttResult === undefined) {
		// user canceled
		return;
	}

	const lttIdentifier = lttResult.values[keys["lttIdentifier"]];
	assert(isString(lttIdentifier));
	const measureSheet = lttResult.values[keys["measureSheet"]];
	assert(isBoolean(measureSheet));
	const loadingSystem = lttResult.values[keys["loadingSystem"]];
	assert(isTrumpfLoadingSystem(loadingSystem));
	const evaporate = lttResult.values[keys["evaporate"]];
	assert(isLstEvaporateMode(evaporate));
	const measuringCorner = lttResult.values[keys["measuringCorner"]];
	assert(isSheetCorner(measuringCorner));
	const sheetMetalStop = lttResult.values[keys["sheetMetalStop"]];
	assert(isSheetCorner(sheetMetalStop));

	const lstCreationInfo: LstCreationInfo = {
		lttIdentifier: lttIdentifier,
		measureSheet: measureSheet,
		loadingSystem: loadingSystem,
		evaporate: evaporate,
		measuringCorner: measuringCorner,
		sheetMetalStop: sheetMetalStop,
	};

	writeLstCreationInfoToSettings(lstCreationInfo);

	const lst = createLst(sheetVertex, lstCreationInfo);
	if (lst === "") {
		showError(wsi4.util.translate("export_lst"), wsi4.util.translate("lst_generation_error_description"));
		return;
	}

	if (!wsi4.io.fs.writeFile(path + ".lst", wsi4.util.stringToArrayBuffer(lst))) {
		showError(wsi4.util.translate("export_lst"), wsi4.util.translate("lst_export_error_description"));
		return;
	}
}

export function editBendLineEngravingMode(rootIdKeys: unknown): void {
	const relevantVertices: Vertex[] = (() => {
		const vertices: Vertex[] = [];
		rootIdKeysToVertices(rootIdKeys)
			.forEach(inputVertex => {
				const article = wsi4.graph.article(inputVertex);
				vertices.push(...article.filter(vertex => {
					const wst = wsi4.node.workStepType(vertex);
					return wst === "sheetCutting" || wst === "sheetBending";
				}));
			});
		return vertices.filter((lhs, index, self) => index === self.findIndex(rhs => isEqual(lhs, rhs)));
	})();

	if (relevantVertices.length === 0) {
		return showInfo(wsi4.util.translate("no_valid_node_found_title"), wsi4.util.translate("no_valid_node_found_text."));
	}

	const initialValue = nodeUserDatumOrDefault("bendLineEngravingMode", back(relevantVertices));

	const values = exhaustiveStringTuple<BendLineEngravingMode>()(
		"none",
		"upwardOnly",
		"downwardOnly",
		"all",
	);

	const formKey = "mode";
	const dialogResult = showFormWidget([
		createDropDownRow<BendLineEngravingMode>({
			key: formKey,
			name: wsi4.util.translate("bend_line_engraving"),
			values: values,
			toId: mode => mode,
			toName: mode => {
				switch (mode) {
					case "none": return "-";
					case "upwardOnly": return wsi4.util.translate("upward_bends");
					case "downwardOnly": return wsi4.util.translate("downward_bends");
					case "all": return wsi4.util.translate("all_bends");
				}
			},
			computeInitialValueIndex: values => values.indexOf(initialValue),
		}),
	]);

	if (dialogResult === undefined) {
		// User canceled
		return;
	}

	const selectedMode = dialogResult.values[formKey];
	assert(isBendLineEngravingMode(selectedMode));

	const verticesWithUserData: VertexWithUserData[] = [];
	relevantVertices.forEach(vertex => {
		const userData = wsi4.node.userData(vertex);
		const currentMode = nodeUserDatumOrDefault("bendLineEngravingMode", vertex, userData);
		if (currentMode === selectedMode) {
			return;
		}

		verticesWithUserData.push({
			vertex: vertex,
			userData: createUpdatedNodeUserData("bendLineEngravingMode", vertex, selectedMode, userData),
		});
	});

	if (verticesWithUserData.length === 0) {
		// All values already up-to-date; nothing left to do
		return;
	}

	changeNodesUserDataImpl(verticesWithUserData);
}
