// Note: All functions in this module must conform to DocumentGraphHandler's script engine API.
import {
	CamCommandType,
	Color,
	GeometryEntityType,
	ProcessType,
	ReplyStateIndicatorSheetCutting,
	TableType,
	WorkStepType,
} from "qrc:/js/lib/generated/enum";
import {
	isCamCommandSetColor,
} from "qrc:/js/lib/generated/typeguard";

import {
	tolerances,
} from "./constants";
import {
	checkConstraints,
} from "./constraints";
import {
	squareNorm2,
} from "./geometry_utils";
import {
	getAssociatedSheetMaterialId,
	sheetIdForVertex,
} from "./graph_utils";
import {
	computeManufacturingStateImpl,
	ConstraintLevelMap,
	isExportReadyImpl,
	ManufacturingState,
	ReplyStateLevelMap,
	WstConstraintMap,
} from "./manufacturing_state";
import {
	minConstraintLevelMap,
	minReplyStateLevelMap,
} from "./min_manufacturing_state";
import {
	isBaseProcessType,
} from "./process";
import {
	getSettingOrDefault,
} from "./settings_table";
import {
	addSheetTappingNumbersToScene,
	addSheetTappingTextToScene,
	computeTappingCandidates,
} from "./sheet_tapping_utils";
import {
	BendParamsUniqueMembers,
	getMutableTable,
	getTable,
	pickBendParameterRow,
	tubeProfileForGeometry,
} from "./table_utils";
import {
	tubeCuttingLayerDescriptor,
} from "./tubecutting_util";
import {
	accessAllUserData,
	collectNodeUserDataEntries,
	getNodeUserDataEntry,
	getNodeUserDataEntryOrThrow,
	isCompatibleToNodeUserDataEntry,
	userDataAccess,
} from "./userdata_utils";
import {
	assert,
	assertDebug,
	asyncRenderDefaultPng,
	bbDimensionX,
	bbDimensionY,
	cleanFileName,
	computeContourCount,
	computeContourLength,
	computeEntityFaceArea,
	computeSegmentLength,
	getKeysOfObject,
	isEqual,
} from "./utils";

export function boundingBox3d(vertex: Vertex): Box3|undefined {
	const assembly = wsi4.node.assembly(vertex);
	if (assembly) {
		return wsi4.geo.util.boundingBox3d(assembly);
	}
	const inputAssembly = wsi4.node.inputAssembly(vertex);
	if (inputAssembly) {
		return wsi4.geo.util.boundingBox3d(inputAssembly);
	}
	return undefined;
}

export function finalVertex(): Vertex|undefined {
	const articles = wsi4.graph.articles();
	for (const article of articles) {
		for (const vertex of article) {
			if (wsi4.graph.sources(vertex).length !== 0 && wsi4.graph.targets(vertex).length === 0) {
				return vertex;
			}
		}
	}
	return undefined;
}

export function nodeLaserSheetCuttingGasId(vertex: Vertex): string|undefined {
	const cuttingGas = getNodeUserDataEntry("cuttingGas", vertex);
	return cuttingGas === undefined ? undefined : cuttingGas.identifier;
}

export function sheetBendingWsNumBendLines(vertex: Vertex): number|undefined {
	const workStepType = wsi4.node.workStepType(vertex);
	if (workStepType !== "sheetBending") {
		return wsi4.throwError("sheetBendingWsNumBendLines(): unexpected workStepType: " + workStepType);
	}
	const dieChoiceMap = wsi4.node.dieChoiceMap(vertex);
	if (dieChoiceMap === undefined) {
		return undefined;
	}
	return dieChoiceMap.length;
}

export function sheetCuttingWsContourCount(vertex: Vertex): number|undefined {
	const workStepType = wsi4.node.workStepType(vertex);
	if (workStepType !== "sheetCutting") {
		return wsi4.throwError("sheetCuttingWSContourCount(): unexpected workStepType:" + workStepType);
	}
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return wsi4.throwError("TwoDimRepresentation missing");
	}
	return computeContourCount(twoDimRep);
}

export function sheetCuttingWsContourLength(vertex: Vertex): number|undefined {
	const workStepType = wsi4.node.workStepType(vertex);
	if (workStepType !== "sheetCutting") {
		return wsi4.throwError("sheetCuttingWsContourLength(): unexpected workStepType: " + workStepType);
	}
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return wsi4.throwError("TwoDimRepresentation missing");
	}
	return computeContourLength(twoDimRep);
}

function mappedBendMaterialId(sheetMaterialId: string): string|undefined {
	const materialMappings = getTable(TableType.sheetBendingMaterialMapping);
	const found = materialMappings.find(row => row.sheetMaterialId === sheetMaterialId);
	if (!found || typeof found.sheetBendingMaterialId !== "string") {
		wsi4.util.error("mappedBendMaterialId(): Material not mapped to bend-material.");
		return undefined;
	}
	return found.sheetBendingMaterialId;
}

/**
 * Available BendDieChoice candidates for a specific bend
 */
export interface BendDieChoiceCandidatesEntry {
	bendDescriptor: number;
	bendDieChoices: BendDieChoice[];
}

function flangesMatchLowerDie(
	bendDieChoice: Readonly<BendDieChoice>,
	lowerDieGroupTable: readonly Readonly <LowerDieGroup>[],
	bendLineData: Readonly<BendLineData>,
	flangeLength: Readonly<BendLineFlangeLength>,
): boolean {
	const found = lowerDieGroupTable.find(element => element.identifier === bendDieChoice.lowerDieGroupId);
	if (!found || typeof found.openingWidth !== "number" || found.openingWidth <= 0) {
		if (bendDieChoice.lowerDieGroupId.length !== 0) {
			wsi4.util.error("computeBendDieChoiceCandidates(): lower die group not found in table: " + bendDieChoice.lowerDieGroupId);
		}
		return true;
	}

	// The amount of extra length we require to be "safe".
	const extraFlangeLength = getSettingOrDefault("bendFlangeSafetyDistance");

	// Max. Angle that is considered manufacturable without splitting up the bend processes into two bends
	//
	// Example for two-step-bend:
	// Seamed edge with resulting angle of 180°; first bend's resulting angle: 130°; second bend's resulting angle: 180°
	//
	// Considered max angle:  153.00°
	const maxSingleBendAngle = 0.85 * Math.PI;

	//    o/2   o/2
	//  |<--->|<--->|
	//
	//  ......|
	//  \ξ    |     /
	//   \    |    /
	//    \   |   /
	// fl0 \  |  / fl1
	//      \β|β/
	//       \|/
	//        \ α
	//         \
	//          \
	// fl0, fl1:  bend legs
	// o = opening width of the sheet
	// α = bend-angle
	// β = 0.5 * (π - α)
	// ξ = 0.5 * π - β = 0.5 * α

	const checkDist = .5 * found.openingWidth;
	const bendAngle = Math.min(maxSingleBendAngle, Math.abs(bendLineData.bendAngle));
	assert(bendAngle >= 0, "bendAngle must be non-negative");
	const factor = Math.cos(.5 * bendAngle);
	const fl0 = factor * (flangeLength.flangeLengthLhs - extraFlangeLength);
	const fl1 = factor * (flangeLength.flangeLengthRhs - extraFlangeLength);

	return fl0 >= checkDist && fl1 >= checkDist;
}

function dieChoiceMatchesDieUnitConstraintsImpl<Unit>(
	bendLineData: Readonly<BendLineData>,
	dies: readonly Readonly<UpperDie|LowerDie>[],
	units: readonly Readonly<Unit>[],
	extractDieId: (unit: Unit) => string,
	extractDimXSum: (unit: Unit) => number,
) {
	const firstSegment = bendLineData.segments[0];
	const lastSegment = bendLineData.segments[bendLineData.segments.length - 1];
	const bendLineLength = Math.sqrt(
		squareNorm2(
			firstSegment.content.from,
			lastSegment.content.to,
		),
	);
	return dies.length === 0 || dies.some(die => bendLineLength <= units.filter(row => extractDieId(row) === die.identifier)
		.reduce((acc, unit) => acc + extractDimXSum(unit), 0));
}

function dieChoiceMatchesUpperDieUnitConstraints(
	bendDieChoice: Readonly<BendDieChoice>,
	bendLineData: Readonly<BendLineData>,
	upperDies: readonly Readonly<UpperDie>[],
	upperDieUnits: readonly Readonly<UpperDieUnit>[],
): boolean {
	const dies = upperDies.filter(row => row.upperDieGroupId === bendDieChoice.upperDieGroupId);
	return dieChoiceMatchesDieUnitConstraintsImpl(
		bendLineData,
		dies,
		upperDieUnits,
		unit => unit.upperDieId,
		unit => unit.multiplicity * unit.dimX,
	);
}

function dieChoiceMatchesLowerDieUnitConstraints(
	bendDieChoice: Readonly<BendDieChoice>,
	bendLineData: Readonly<BendLineData>,
	lowerDies: readonly Readonly<LowerDie>[],
	lowerDieUnits: readonly Readonly<LowerDieUnit>[],
): boolean {
	const dies = lowerDies.filter(row => row.lowerDieGroupId === bendDieChoice.lowerDieGroupId);
	return dieChoiceMatchesDieUnitConstraintsImpl(
		bendLineData,
		dies,
		lowerDieUnits,
		unit => unit.lowerDieId,
		unit => unit.multiplicity * unit.dimX,
	);
}

export function computeBendDieChoiceCandidates(vertex: Vertex): BendDieChoiceCandidatesEntry[] {
	const materials = collectNodeUserDataEntries("sheetMaterial", vertex, userDataAccess.article);
	if (materials.length !== 1) {
		wsi4.util.error("computeBendDieChoiceCandidates(): Expecting exactly one material");
		return [];
	}
	const sheetBendingMaterialId = mappedBendMaterialId(materials[0].identifier);
	if (sheetBendingMaterialId === undefined) {
		wsi4.util.error("computeBendDieChoiceCandidates(): no bend material available");
		return [];
	}
	const thickness = wsi4.node.sheetThickness(vertex);
	if (typeof thickness !== "number") {
		wsi4.util.error("computeBendDieChoiceCandidates(): Thickness not available");
		return [];
	}
	const bendLineDataArray = wsi4.node.computeBendLineData(vertex);
	if (!bendLineDataArray) {
		wsi4.util.error("computeBendDieChoiceCandidates(): Bend info not available");
		return [];
	}
	const upperDieGroups = getTable(TableType.upperDieGroup);
	const lowerDieGroups = getTable(TableType.lowerDieGroup);

	const upperDies = getTable(TableType.upperDie);
	const lowerDies = getTable(TableType.lowerDie);

	const upperDieUnits = getTable(TableType.upperDieUnit);
	const lowerDieUnits = getTable(TableType.lowerDieUnit);

	const unfilteredBendDeductions = getTable(TableType.bendDeduction);
	const bendDeductions = unfilteredBendDeductions.filter(bendDeduction => bendDeduction.sheetBendingMaterialId === sheetBendingMaterialId
		// Allows filtering bend deductions by removing entries from the upper die group table
		&& upperDieGroups.find(upperDieGroup => upperDieGroup.identifier === bendDeduction.upperDieGroupId) !== undefined
		// Allows filtering bend deductions by removing entries from the lower die group table
		&& lowerDieGroups.find(lowerDieGroup => lowerDieGroup.identifier === bendDeduction.lowerDieGroupId) !== undefined
	);

	const bendLineFlangeLengths = wsi4.node.computeBendLineFlangeLengths(vertex);
	if (bendLineFlangeLengths === undefined) {
		wsi4.util.error("computeBendDieChoiceCandidates(): Flange lengths not available");
		return [];
	}

	const dieGroupPriorities = getMutableTable(TableType.dieGroupPriority)
		.filter(row => row.sheetBendingMaterialId === sheetBendingMaterialId
			&& row.sheetThickness <= (thickness + tolerances.thickness))
		.sort((lhs, rhs) => rhs.sheetThickness - lhs.sheetThickness)
		.filter((lhs, index, self) => index === self.findIndex(rhs => lhs.upperDieGroupId === rhs.upperDieGroupId && lhs.lowerDieGroupId === rhs.lowerDieGroupId));

	const lowerDieGroupTable = getTable(TableType.lowerDieGroup);
	return bendLineDataArray
		.map((bendLineData: BendLineData) => {
			const query = {thickness: thickness,
				bendAngle: Math.abs(bendLineData.bendAngle)};
			const constructedRadius = bendLineData.constructedInnerRadius;
			const flangeLength = bendLineFlangeLengths.find(entry => entry.bendDescriptor === bendLineData.bendDescriptor);
			assert(flangeLength !== undefined, "Expecting valid bend line flange length entry");
			return {
				bendDescriptor: bendLineData.bendDescriptor,
				bendDieChoices: wsi4.cam.bend.selectDieGroups(bendDeductions, upperDieGroups, lowerDieGroups, dieGroupPriorities, query, constructedRadius)
					.filter(bendDieChoice => bendDieChoice.type === "neutralAxis" || flangesMatchLowerDie(
						bendDieChoice,
						lowerDieGroupTable,
						bendLineData,
						flangeLength,
					))
					.filter(bendDieChoice => bendDieChoice.type === "neutralAxis" || dieChoiceMatchesUpperDieUnitConstraints(
						bendDieChoice,
						bendLineData,
						upperDies,
						upperDieUnits,
					))
					.filter(bendDieChoice => bendDieChoice.type === "neutralAxis" || dieChoiceMatchesLowerDieUnitConstraints(
						bendDieChoice,
						bendLineData,
						lowerDies,
						lowerDieUnits,
					)),
			};
		});
}

export function computeUpperDieAffectDistances(vertex: Vertex): Array<number> {
	const upperDieGroupTable = getTable(TableType.upperDieGroup);
	const bendLineDataArray = wsi4.node.computeBendLineData(vertex);
	if (!bendLineDataArray) {
		wsi4.util.error("computeUpperDieAffectDistances(): No bend info available for vertex " + vertex);
		return [];
	}
	const dieChoiceArray = wsi4.node.dieChoiceMap(vertex);
	if (dieChoiceArray === undefined) {
		wsi4.util.error("computeUpperDieAffectDistances(): die choice missing for vertex " + vertex);
		return [];
	}
	return dieChoiceArray.map(entry => {
		const upperDieGroup = upperDieGroupTable.find(element => element.identifier === entry.bendDieChoice.upperDieGroupId);
		if (upperDieGroup === undefined) {
			if (entry.bendDieChoice.upperDieGroupId.length !== 0) {
				wsi4.util.error("computeUpperDieAffectDistances(): upper die group not found in table: " + entry.bendDieChoice.upperDieGroupId);
			}
			return 0;
		} else {
			const bendLineData = bendLineDataArray.find(data => data.bendDescriptor === entry.bendDescriptor);
			assert(bendLineData !== undefined, "Expecting valid bend line data entry");

			const angle = Math.min(Math.abs(bendLineData.bendAngle), 150 / 180 * Math.PI);
			return (Math.PI - angle) * upperDieGroup.radius;
		}
	});
}

export function computeLowerDieAffectDistances(vertex: Vertex): Array<number> {
	const lowerDieGroupTable = getTable(TableType.lowerDieGroup);
	const thickness = wsi4.node.sheetThickness(vertex);
	if (typeof thickness !== "number") {
		wsi4.util.error("computeLowerDieAffectDistances(): Sheet thickness not available");
		return [];
	}
	const bendLineDataArray = wsi4.node.computeBendLineData(vertex);
	if (!bendLineDataArray) {
		wsi4.util.error("computeLowerDieAffectDistances(): No bend info available for vertex " + vertex);
		return [];
	}
	const dieChoiceArray = wsi4.node.dieChoiceMap(vertex);
	if (dieChoiceArray === undefined) {
		wsi4.util.error("computeLowerDieAffectDistances(): die choice missing for vertex " + vertex);
		return [];
	}

	if (dieChoiceArray.length !== bendLineDataArray.length) {
		wsi4.util.error("computeLowerDieAffectDistances(): inconsistent sizes for die choice (size " + dieChoiceArray.length + ") and bendLineData (size " + bendLineDataArray.length + ")");
		return [];
	}

	return dieChoiceArray.map(entry => {
		const lowerDieGroup = lowerDieGroupTable.find(element => element.identifier === entry.bendDieChoice.lowerDieGroupId);
		if (lowerDieGroup === undefined) {
			if (entry.bendDieChoice.lowerDieGroupId.length !== 0) {
				wsi4.util.error("computeLowerDieAffectDistances(): lower die group not found in table: " + entry.bendDieChoice.lowerDieGroupId);
			}
			return 0;
		} else {
			const bendLineData = bendLineDataArray.find(data => data.bendDescriptor === entry.bendDescriptor);
			assert(bendLineData !== undefined, "Expecting valid bend line data");

			const o = .5 * lowerDieGroup.openingWidth;
			const innerRadius = entry.bendDieChoice.baseClass.innerRadius;
			const outerRadius = innerRadius + thickness;
			const alpha = Math.min(.5 * 150 / 180 * Math.PI, .5 * Math.abs(bendLineData.bendAngle));
			const straightPart = (o - Math.sin(alpha) * outerRadius) / Math.cos(alpha);
			const roundPart = outerRadius * alpha;
			return roundPart + straightPart + entry.bendDieChoice.baseClass.roundDeduction;
		}
	});
}

function generateUniqueSceneIdentifier(vertex: Vertex): string {
	const r = wsi4.util.toKey(wsi4.node.rootId(vertex));
	return (new Date())
		.toISOString() + "_" + r + "_";
}

export function computeSceneSceneData(vertex: Vertex): SceneSceneData {
	// FIXME: Handle tube material (see #2405)
	const globalMaterial = (() => {
		const materials = collectNodeUserDataEntries("sheetMaterial", vertex, accessAllUserData);
		if (materials.length !== 1) {
			return "";
		}
		return materials[0].identifier;
	})();
	const material = (() => {
		const materials = collectNodeUserDataEntries("sheetMaterial", vertex, accessAllUserData);
		if (materials.length !== 1) {
			return "";
		}
		const materialId = materials[0].identifier;
		const workStepType = wsi4.node.workStepType(vertex);
		const mappedMaterialId = workStepType === WorkStepType.sheetBending ? mappedBendMaterialId(materialId)
			: workStepType === WorkStepType.sheetCutting ? mappedSheetCuttingMaterialId(materialId)
				: materialId;
		return mappedMaterialId ?? "";
	})();
	const thickness = wsi4.node.sheetThickness(vertex) ?? 0.;
	return {
		// Note: must conform to wscam's interface
		material: material,
		thickness: thickness,
		identifier: generateUniqueSceneIdentifier(vertex),
		comment: "",
		globalMaterial: globalMaterial,
	};
}

/**
 * Array of SceneObjectData, indexed by BendDescriptor:s.
 */
export function computeBendSceneObjectDatas(vertex: Vertex): Array<SceneObjectData> {
	const dieChoiceArray = wsi4.node.dieChoiceMap(vertex);
	if (dieChoiceArray === undefined) {
		wsi4.util.error("computeBendSceneObjectDatas(): dieChoiceArray missing");
		return [];
	}
	const bendLineDataArray = wsi4.node.computeBendLineData(vertex);
	if (!bendLineDataArray) {
		wsi4.util.error("computeBendSceneObjectDatas(): bendLineData missing");
		return [];
	}
	if (dieChoiceArray.length !== bendLineDataArray.length) {
		wsi4.util.error("computeBendSceneObjectDatas(): inconsistent bend data");
		return [];
	}

	const upperDieGroups = getTable(TableType.upperDieGroup);
	const lowerDieGroups = getTable(TableType.lowerDieGroup);

	return dieChoiceArray.map(entry => {
		const bendLineData = bendLineDataArray.find(bld => bld.bendDescriptor === entry.bendDescriptor);
		if (bendLineData === undefined) {
			wsi4.util.info(JSON.stringify(bendLineDataArray));
			wsi4.throwError("Cannot find BendDescriptor " + entry.bendDescriptor + " in bendLineData");
		}

		const getExportId = (table: readonly Readonly<UpperDieGroup|LowerDieGroup>[], id: string) => {
			const entry = table.find(row => row.identifier === id);
			return entry === undefined ? "" : entry.exportIdentifier;
		};

		return {
			zValue: 1,
			upperDieGroup: getExportId(upperDieGroups, entry.bendDieChoice.upperDieGroupId),
			lowerDieGroup: getExportId(lowerDieGroups, entry.bendDieChoice.lowerDieGroupId),
			bendAngle: bendLineData.bendAngle,
			innerRadius: entry.bendDieChoice.baseClass.innerRadius,
			sharpDeduction: entry.bendDieChoice.sharpDeduction,
		};
	});
}

export function mappedSheetCuttingMaterialId(sheetMaterialId: string): string|undefined {
	const table = getTable(TableType.sheetCuttingMaterialMapping);
	const found = table.find(row => row.sheetMaterialId === sheetMaterialId);
	if (found === undefined) {
		wsi4.util.error("mappedSheetCuttingMaterialId(): no mapped laser sheet cutting material available for sheetMaterialId \"" + sheetMaterialId + "\"");
		return undefined;
	}
	return found.sheetCuttingMaterialId;
}

function createBendSpecificScene(vertex: Vertex, config: Readonly<SceneConfig>, baseScene: Scene): Scene {
	const bendLineData = wsi4.node.computeBendLineData(vertex);
	assert(bendLineData !== undefined, "Expecting valid bend line data");

	const objectDatas = computeBendSceneObjectDatas(vertex);
	let updatedScene = wsi4.geo.util.addBendLinesToScene(baseScene, config.bendLineShowLabels, bendLineData, objectDatas);
	if (config.bendLineShowAffectedSegments) {
		const s = wsi4.node.computeAffectedSegments(vertex);
		if (s !== undefined) {
			updatedScene = wsi4.geo.util.addSegmentsToScene(updatedScene, s, {
				strokeWidth: 2,
				strokeColor: Color.blue,
			});
		}

		const twoDimRep = wsi4.node.twoDimRep(vertex);
		assert(twoDimRep !== undefined, "Expecting valid TwoDimRepresentation");

		const a = wsi4.cam.util.extractOverlappingAreas(twoDimRep);
		if (a.length !== 0) {
			const style: SceneStyle = {
				strokeWidth: 1,
				strokeColor: Color.magenta,
			};
			updatedScene = wsi4.geo.util.addInnerOuterPolygonsToScene(updatedScene, a, style);
		}
	}

	return updatedScene;
}

function createSheetTappingSpecificScene(vertex: Vertex, config: SceneConfig, scene: Scene): Scene {
	switch (config.sheetTappingLabelMode) {
		case "names": {
			const entries = getNodeUserDataEntryOrThrow("sheetTappingData", vertex);
			const cosys = wsi4.node.twoDimRepTransformation(vertex);
			assert(cosys !== undefined, "Expecting valid two dim rep transformation");
			return addSheetTappingTextToScene(scene, entries, cosys);
		}
		case "indices": {
			const thickness = wsi4.node.sheetThickness(vertex);
			assert(thickness !== undefined, "Expecting valid sheet thickness");

			const twoDimRep = wsi4.node.twoDimRep(vertex);
			assert(twoDimRep !== undefined, "Expecting valid TwoDimRepresentation");

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

			const iops = wsi4.cam.util.extractInnerOuterPolygons(twoDimRep);
			assert(iops.length === 1, "Expecting one InnerOuterPolygon");

			const candidates = computeTappingCandidates(iops[0], thickness);
			return addSheetTappingNumbersToScene(scene, candidates);
		}
	}
}

export interface SceneConfig
{
	bendLineShowLabels: boolean;
	bendLineShowAffectedSegments: boolean;
	sheetTappingLabelMode: "names"|"indices";
	tubeCuttingShowTubeContours: boolean;
	tubeCuttingShowVirtualCuts: boolean;
}

function completeSceneConfig(config: Partial<SceneConfig>): SceneConfig {
	const defaultValues: {[index in keyof SceneConfig]: SceneConfig[index]} = {
		bendLineShowLabels: false,
		bendLineShowAffectedSegments: false,
		sheetTappingLabelMode: "names",
		tubeCuttingShowTubeContours: false,
		tubeCuttingShowVirtualCuts: false,
	};
	getKeysOfObject(defaultValues)
		.forEach(key => {
			if (config[key] === undefined) {
				config[key] = defaultValues[key] as any;
			}
		});
	return config as SceneConfig;
}

function styleForLayer(layer: Layer): SceneStyle {
	const colorMap = [
		Color.closedContour,
		Color.closedContour,
		Color.engraving,
		Color.green,
		Color.cyan,
		Color.blue,
		Color.magenta,
		Color.white,
		Color.red,
	];
	const color = (layer.number >= 0 && layer.number < colorMap.length) ? colorMap[layer.number] : Color.closedContour;
	const style: SceneStyle = {
		strokeWidth: 1,
		strokeColor: color,
	};
	return style;
}

function addLayeredToScene(scene: Scene, layered: Layered, ldsToSkip: readonly number[]): Scene {
	wsi4.geo.util.layers(layered)
		.filter(layer => ldsToSkip.every(ld => ld !== layer.descriptor))
		.forEach(layer => {
			const style = styleForLayer(layer);
			scene = wsi4.geo.util.addLayersToScene(scene, layered, [ layer.descriptor ], style);
		});
	return scene;
}

/**
 * Create a [[Scene]] representing a given vertex's 2d view.
 *
 * @param vertex The Vertex to generate the Scene for
 * @param partialConfig Configuration for the scene
 * @returns The 2d representation of vertex in a Scene
 */
export function sceneForVertex(vertex: Vertex, partialConfig: Partial<SceneConfig>): Scene {
	const config = completeSceneConfig(partialConfig);
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	const layered = wsi4.node.layered(vertex);
	assert((twoDimRep === undefined) !== (layered === undefined), "Expecting either layered or twoDimRep");

	const emptyScene = (() => {
		const sceneSceneData = computeSceneSceneData(vertex);
		return wsi4.geo.util.createScene(sceneSceneData);
	})();

	const processType = wsi4.node.processType(vertex);

	const baseScene = (() => {
		if (twoDimRep !== undefined) {
			const style: SceneStyle = {
				strokeWidth: 1,
				strokeColor: Color.closedContour,
			};
			if (processType === ProcessType.sheet) {
				return wsi4.cam.util.addNestingToScene(emptyScene, twoDimRep, style);
			} else {
				const iops = wsi4.cam.util.extractInnerOuterPolygons(twoDimRep);
				return wsi4.geo.util.combineScenes([
					wsi4.geo.util.addInnerOuterPolygonsToScene(emptyScene, iops, style),
					wsi4.cam.util.extractEngravings(twoDimRep),
				]);
			}
		} else {
			assert(layered !== undefined);
			if (processType === ProcessType.tubeCutting) {
				const ldsToSkip = (() => {
					if (config.tubeCuttingShowTubeContours) {
						return [];
					} else {
						return [ tubeCuttingLayerDescriptor("tubeOutlines", layered) ];
					}
				})();
				return addLayeredToScene(emptyScene, layered, ldsToSkip);
			} else {
				return addLayeredToScene(emptyScene, layered, []);
			}
		}
	})();

	if (processType === ProcessType.dieBending) {
		return createBendSpecificScene(vertex, config, baseScene);
	} else if (processType === ProcessType.sheetTapping) {
		return createSheetTappingSpecificScene(vertex, config, baseScene);
	} else {
		return baseScene;
	}
}

/**
 * Create a [[Scene]] representing a given vertex's 2d view.
 *
 * @param vertex The Vertex to generate the Scene for
 * @returns The 2d representation of vertex in a Scene
 *
 * Convenience function utilizing the default scene config.
 */
export function defaultSceneForVertex(vertex: Vertex): Scene {
	return sceneForVertex(vertex, {});
}

/**
 * Create a [[Scene]] representing a given vertex's 2d view.
 *
 * @param vertex The Vertex to generate the Scene for
 * @param labels Whether to add text labels
 * @param affectedSegments Whether to draw all segments affected by bend zones extra to highlight them
 * @returns The 2d representation of vertex in a Scene
 */
export function createSceneForVertex(vertex: Vertex, labels: boolean, affectedSegments: boolean): Scene {
	wsi4.util.warn("createSceneForVertex() is deprecated.  Consider using sceneForVertex() instead.");
	return sceneForVertex(
		vertex,
		{
			bendLineShowLabels: labels,
			bendLineShowAffectedSegments: affectedSegments,
		},
	);
}

export function computeMaxBendLineNetLength(vertex: Vertex): number {
	const bendLineData = wsi4.node.computeBendLineData(vertex);
	if (bendLineData === undefined || bendLineData.length === 0) {
		return wsi4.throwError("Expecting valid bend line data");
	}
	return bendLineData.map(bld => bld.segments.reduce((acc, segment) => acc + computeSegmentLength(segment), 0))
		.sort((lhs, rhs) => rhs - lhs)[0];
}

/**
 * Compute area of all selected entities for the vertex
 *
 * Pre-condition: associated node provides assembly
 * Pre-condition: associated node is of WorkStepType transform
 * Pre-condition: all underlying AssemblyPath:s are valid for assembly
 * Pre-condition: all resolved Assembly:s provide as associated brep
 */
export function computeCommandsFaceArea(vertex: Vertex): number {
	const rootAssembly = wsi4.node.assembly(vertex);
	assert(rootAssembly !== undefined, "Expecting assembly");
	assert(wsi4.node.workStepType(vertex) === WorkStepType.transform, "Expecting WorkStepType transform");
	return wsi4.node.commands(vertex)
		.reduce((mainAcc, command) => { // eslint-disable-line array-callback-return
			switch (command.type) {
				case CamCommandType.setColor: {
					assert(isCamCommandSetColor(command.content), "Expecting CamCommandSetColor");
					return mainAcc + command.content.entities.reduce((acc, entity) => acc + computeEntityFaceArea(rootAssembly, entity), 0);
				}
			}
		}, 0);
}

type PureBendParams<T> = Omit<T, "sheetBendingMaterialId"|"thickness"|"bendLineNetLength">;
function lookUpBendParams<T extends BendParamsUniqueMembers>(vertex: Vertex, table: readonly Readonly<T>[], defaultValue: PureBendParams<T>): PureBendParams<T> {
	const actualThickness = wsi4.node.sheetThickness(vertex);
	if (actualThickness === undefined) {
		return wsi4.throwError("Expecting sheet actualThickness");
	}
	const maxThickness = actualThickness + tolerances.thickness;

	const sheetMaterialId = getAssociatedSheetMaterialId(vertex);
	const materialMapping = getTable(TableType.sheetBendingMaterialMapping)
		.find(row => row.sheetMaterialId === sheetMaterialId);
	if (materialMapping === undefined) {
		return wsi4.throwError("Tables inconsistent.  Expecting bend material for sheetMaterialId " + sheetMaterialId);
	}

	const maxBendLineNetLength = computeMaxBendLineNetLength(vertex) + tolerances.thickness;
	const row = pickBendParameterRow(table, materialMapping.sheetBendingMaterialId, maxThickness, maxBendLineNetLength);
	return row === undefined ? defaultValue : row;
}

export type PureBendTimeParams = Omit<BendTimeParameters, "sheetBendingMaterialId"|"thickness"|"bendLineNetLength">;

/**
 * Table value is not enforced for all possible unique member combinations.
 * If no matching row is available a neutral default value is returned.
 */
export function lookUpBendTimeParams(vertex: Vertex, table?: readonly Readonly<BendTimeParameters>[]): PureBendTimeParams {
	table = table === undefined ? getTable(TableType.bendTimeParameters) : table;
	const defaultValue = {
		setupTimeFactor: 1.,
		setupTimeDelta: 0.,
		setupTimePerBendFactor: 1.,
		setupTimePerBendDelta: 0.,
		unitTimeFactor: 1.,
		unitTimeDelta: 0.,
		unitTimePerBendFactor: 1.,
		unitTimePerBendDelta: 0.,
	};
	return lookUpBendParams(vertex, table, defaultValue);
}

export type PureBendRateParams = Omit<BendRateParameters, "sheetBendingMaterialId"|"thickness"|"bendLineNetLength">;

/**
 * Table value is not enforced for all possible unique member combinations.
 * If no matching row is available a neutral default value is returned.
 */
export function lookUpBendRateParams(vertex: Vertex, table?: readonly Readonly<BendRateParameters>[]): PureBendRateParams {
	table = table === undefined ? getTable(TableType.bendRateParameters) : table;
	const defaultValue = {
		hourlyRateFactor: 1.,
		hourlyRateDelta: 0.,
	};
	return lookUpBendParams(vertex, table, defaultValue);
}

export function computeNameProposal(vertex: Vertex): string {
	const assemblyName = (() => {
		const getAssemblyName = ((assembly: Assembly|undefined) => {
			if (assembly === undefined) {
				return "";
			} else {
				return wsi4.geo.assembly.name(assembly);
			}
		});

		const inputAssemblyName = getAssemblyName(wsi4.node.inputAssembly(vertex));
		if (inputAssemblyName === "") {
			return cleanFileName(getAssemblyName(wsi4.node.assembly(vertex)));
		} else {
			return cleanFileName(inputAssemblyName);
		}
	})();
	if (assemblyName === "") {
		// Appending _0 by default to ensure all generic names have the same format
		// Creating unique names is *not* the job of this function
		const processType = wsi4.node.processType(vertex);
		if (isBaseProcessType(processType)) {
			return wsi4.util.translate(processType) + "_0";
		} else {
			return wsi4.util.translate("Article") + "_0";
		}
	} else {
		return assemblyName;
	}
}

/**
 * Convenience function to compute manufacturing state for a vertex and its unterlying `WorkStepType`
 */
export function computeActualManufacturingState(vertex: Readonly<Vertex>, replyStateLevelMap: ReplyStateLevelMap, constraintLevelMap: ConstraintLevelMap): ManufacturingState {
	const wst = wsi4.node.workStepType(vertex);
	const wstConstraintMap = (() => {
		const m: WstConstraintMap = {};
		m[wst] = checkConstraints(vertex);
		return m;
	})();

	return computeManufacturingStateImpl(
		wsi4.node.camReplyStateIndicators(vertex),
		wstConstraintMap,
		replyStateLevelMap,
		constraintLevelMap,
		[ wst ],
	);
}

/**
 * Convenience function to compute manufacturing state for all `WorkStepType`s besides `vertex`'s actual `WorkStepType`
 */
export function computeVirtualManufacturingState(vertex: Readonly<Vertex>, replyStateLevelMap: ReplyStateLevelMap, constraintLevelMap: ConstraintLevelMap): ManufacturingState {
	// Currently constraints are checked for the actual wst only.
	const wstConstraintMap: WstConstraintMap = {};

	const actualWst = wsi4.node.workStepType(vertex);
	const targetWsts = Array.from(WorkStepType)
		.filter(wst => wst !== actualWst);

	return computeManufacturingStateImpl(
		wsi4.node.camReplyStateIndicators(vertex),
		wstConstraintMap,
		replyStateLevelMap,
		constraintLevelMap,
		targetWsts,
	);
}

/**
 * @returns true if a certain minimal requirements are fulfilled.
 *
 * This function can be used e.g. in contexts where certain minimal requirements need
 * to be asserted regardless of actual context (e.g. gui vs. shop).
 */
export function isMinExportReady(vertex: Vertex): boolean {
	const manufacturingState = computeActualManufacturingState(vertex, minReplyStateLevelMap, minConstraintLevelMap);
	return isExportReadyImpl(manufacturingState);
}

export function isExportReady(vertex: Vertex, replyStateLevelMap: ReplyStateLevelMap, constraintLevelMap: ConstraintLevelMap): boolean {
	const manufacturingState = computeActualManufacturingState(vertex, replyStateLevelMap, constraintLevelMap);
	return isExportReadyImpl(manufacturingState);
}

function geometryEntities(replyState: ReplyStateIndicatorMap, key: "sheetBending"|"sheetCutting"|"tubeCutting"): GeometryEntity[] {
	// Assumption:  geometry entites are computed for the assembly that holds the brep
	const assemblyPath: Readonly<AssemblyPath> = {
		indices: [],
	};

	const map = replyState[key];
	if (map !== undefined) {
		const udf = ReplyStateIndicatorSheetCutting.undetectedFeatures;
		const content = map.replyStateIndicators[udf];
		if (content !== undefined) {
			return content.unassignedFaceDescriptors.map(fd => ({
				assemblyPath: assemblyPath,
				descriptor: {
					type: GeometryEntityType.face,
					content: {
						value: fd,
					},
				},
			}));
		}
	}
	return [];
}

export function extractGeometryErrorEntities(vertex: Vertex): GeometryEntity[] {
	const replyState = wsi4.node.camReplyStateIndicators(vertex);
	const undetectedFeaturesTypes = [
		WorkStepType.sheetCutting,
		WorkStepType.sheetBending,
		WorkStepType.tubeCutting,
	];
	return undetectedFeaturesTypes.reduce((acc: GeometryEntity[], key) => [
		...acc,
		...geometryEntities(replyState, key),
	], []);
}

export interface VertexAnd<Data> {
	vertex: Vertex;
	data: Data;
}

/**
 * Create png futures for vertices providing an Assembly
 *
 * If there is no Assembly for a vertex it will be ignored.
 */
export function createPngFutures(vertices: readonly Vertex[]): VertexAnd<ArrayBufferFuture>[] {
	// Sorting so futures of large assemblies start as early as possible
	return vertices.reduce(
		(acc: [ Vertex, Assembly, number ][], vertex) => {
			const assembly = wsi4.node.assembly(vertex) ?? wsi4.node.inputAssembly(vertex);
			if (assembly !== undefined) {
				acc.push([
					vertex,
					assembly,
					wsi4.geo.assembly.recursiveSubAssemblies(assembly).length,
				]);
			}
			return acc;
		},
		[])
		.sort(([
			_rhsVertex,
			_rhsAssembly,
			rhsNumSubAsms,
		], [
			_lhsVertex,
			_lhsAssembly,
			lhsNumSubAsms,
		]) => rhsNumSubAsms - lhsNumSubAsms)
		.map(([
			vertex,
			assembly,
		]) => ({
			vertex: vertex,
			data: asyncRenderDefaultPng(assembly),
		}));
}

export interface ManufacturingStateCache {
	entries: [Vertex, ManufacturingState][];
}

export function createManufacturingStateCache(): ManufacturingStateCache {
	return {
		entries: [],
	};
}

export function computeActualManufacturingStateCached(vertex: Vertex, replyStateLevelMap: ReplyStateLevelMap, constraintLevelMap: ConstraintLevelMap, cache: ManufacturingStateCache): ManufacturingState {
	const entry = cache.entries.find(entry => isEqual(entry[0], vertex));
	if (entry === undefined) {
		const result = computeActualManufacturingState(vertex, replyStateLevelMap, constraintLevelMap);
		cache.entries.push([
			vertex,
			result,
		]);
		return result;
	} else {
		return entry[1];
	}
}

export function isTestReportRequiredForSheet(sheetVertex: Vertex): boolean {
	assert(wsi4.node.workStepType(sheetVertex) === WorkStepType.sheet, "Expecting sheet WST");
	return wsi4.graph.reachable(sheetVertex)
		.some(vertex => isCompatibleToNodeUserDataEntry("testReportRequired", vertex) && getNodeUserDataEntryOrThrow("testReportRequired", vertex));
}

export function nestingTargetBoxForVertex(sheetVertex: Vertex): Box2 | undefined {
	assertDebug(
		() => wsi4.node.workStepType(sheetVertex) === WorkStepType.sheet,
		"Pre-condition violated: Expecting WST sheet",
	);

	const twoDimRep = wsi4.node.twoDimRep(sheetVertex);
	if (twoDimRep === undefined) {
		// Valid case; no nesting available
		return undefined;
	}

	// Note: All nestings share the same boundary
	const nestingDescriptor = 0;
	const targetBoundary = wsi4.cam.nest2.nestingTargetBoundary(twoDimRep, nestingDescriptor);
	return wsi4.geo.util.boundingBox2d(targetBoundary);
}

/**
 * For a given nesting compute the sheet consumption (floating-point value)
 *
 * Note: All underlying nestings for a sheet node are (now) based on the same target boundary.
 *
 * Note: If there is no valid nesting for the vertex submitted the consumption is undefined.
 * Note: If there is no valid sheet for the vertex submitted the consumption is undefined.
 *
 * For each sheet the consumption is computed as floating-point value v; v can be
 * >= 1. if *no* modulus operation is applied to the sheet
 * > 0. if modulus operation is applied to the sheet
 * Values > 1. occur for sheets with multiplicity > 1
 *
 * @param vertex Vertex with associated Nesing
 * @returns Sheet consumption (if any; in fractions of one sheet so no unit)
 */
export function computeSheetConsumption(vertex: Vertex, sheetTable?: readonly Readonly<Sheet>[]): number | undefined {
	assertDebug(
		() => wsi4.node.workStepType(vertex) === WorkStepType.sheet,
		"Pre-condition violated: Expecting WST sheet",
	);

	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return undefined;
	}

	const sheetId = sheetIdForVertex(vertex, sheetTable);
	if (sheetId === undefined) {
		return undefined;
	}

	const targetBox = nestingTargetBoxForVertex(vertex);
	assert(targetBox !== undefined, "Execting valid target box");

	const targetBoxDimX = bbDimensionX(targetBox);
	const targetBoxDimY = bbDimensionY(targetBox);

	// Sheet modulus table entry is not mandatory.
	// Using neutral default value as fallback.
	const sheetModulusRow: Readonly<{
		xModulus: number,
		yModulus: number,
		applyToAll: boolean,
	}> = getTable(TableType.sheetModulus)
		.find(row => row.sheetId === sheetId) ?? {
		xModulus: 0,
		yModulus: 0,
		applyToAll: true,
	};

	type NestingFraction = {
		nestingDescriptor: number,
		fractionPerSheet: number,
	};

	const nestingDistance = getSettingOrDefault("sheetNestingDistance");
	const nestingDescriptors: readonly number[] = wsi4.cam.nest2.nestingDescriptors(twoDimRep);
	const nestingFractions: readonly Readonly<NestingFraction>[] = nestingDescriptors.map(nestingDescriptor => {
		const bbox = wsi4.cam.nest2.nestingBoundingBox(twoDimRep, nestingDescriptor);
		const bbDimX = Math.min(nestingDistance + bbDimensionX(bbox), targetBoxDimX);
		const bbDimY = Math.min(nestingDistance + bbDimensionY(bbox), targetBoxDimY);

		const computeEffectiveDim = (bbDim: number, sheetDim: number, modulus: number) => {
			const modulusEps = 0.0001;
			if (modulus < modulusEps) {
				return bbDim;
			} else {
				const modulusFraction = sheetDim * modulus;
				const numModulus = Math.ceil(bbDim / modulusFraction);
				return Math.min(sheetDim, numModulus * modulusFraction);
			}
		};

		const clamp = (modulus: number) => Math.max(0, Math.min(1, modulus));
		const dimX = computeEffectiveDim(bbDimX, targetBoxDimX, clamp(sheetModulusRow.xModulus));
		const dimY = computeEffectiveDim(bbDimY, targetBoxDimY, clamp(sheetModulusRow.yModulus));

		const fraction = (dimX * dimY) / (targetBoxDimX * targetBoxDimY);
		assert(
			fraction > 0 && fraction <= 1,
			"Sheet fraction for nesting invalid; expecting 0 < fraction <= 1; actual fraction: " + fraction.toString(),
		);

		return {
			nestingDescriptor: nestingDescriptor,
			fractionPerSheet: fraction,
		};
	});

	// Undefined if modulus should be applied to all sheets
	const bestNestingDescriptor = (() => {
		if (sheetModulusRow.applyToAll) {
			return undefined;
		} else {
			// If there is at least one sheet with multiplicity === 1 then the sheet where modulus operation is applied must have multiplicity === 1
			const multiplicityOneAvailable = nestingDescriptors.some(nestingDescriptor => wsi4.cam.nest2.nestingMultiplicity(twoDimRep, nestingDescriptor) === 1);
			return nestingFractions.filter(item => !multiplicityOneAvailable || wsi4.cam.nest2.nestingMultiplicity(twoDimRep, item.nestingDescriptor) === 1)
				.sort((lhs, rhs) => lhs.fractionPerSheet - rhs.fractionPerSheet)[0].nestingDescriptor;
		}
	})();

	return nestingFractions.reduce((acc, obj) => {
		const numSheets = wsi4.cam.nest2.nestingMultiplicity(twoDimRep, obj.nestingDescriptor);
		if (bestNestingDescriptor === undefined) {
			return acc + numSheets * obj.fractionPerSheet;
		} else if (obj.nestingDescriptor === bestNestingDescriptor) {
			return acc + numSheets + obj.fractionPerSheet - 1;
		} else {
			return acc + numSheets;
		}
	}, 0);
}

/**
 * Net volume of all nested parts without scrap
 *
 * Inner contours are *not* considered, if their area is below the associated
 * threshold setting (default = 0.)  A threshold > 0 might lead to slightly
 * inaccurate results in case smaller parts are nested within inner contours
 * of other parts.  The worst case sheet consumption is equal to the combined
 * volume of the bounding boxes of all nestings.
 *
 * In combination with the associated total sheet consumption this value is
 * complementary w.r.t the scrap.
 *
 * If there is no valid nesting for `vertex` then the consumption is undefined.
 * If there is no valid sheet for `vertex` then the consumption is undefined.
 */
export function computeSheetConsumptionNestedParts(vertex: Vertex): number | undefined {
	assertDebug(
		() => wsi4.node.workStepType(vertex) === WorkStepType.sheet,
		"Pre-condition violated: Expecting WST sheet",
	);

	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return undefined;
	}

	const targetBox = nestingTargetBoxForVertex(vertex);
	assert(targetBox !== undefined, "Execting valid target box");

	const targetBoxDimX = bbDimensionX(targetBox);
	const targetBoxDimY = bbDimensionY(targetBox);
	const sheetVolume = targetBoxDimX * targetBoxDimY;

	const areaThreshold = getSettingOrDefault("sheetScrapAreaThreshold");

	const nestingDescriptors = wsi4.cam.nest2.nestingDescriptors(twoDimRep);
	const partNetVolume = nestingDescriptors.reduce((acc, nd) => {
		const mult = wsi4.cam.nest2.nestingMultiplicity(twoDimRep, nd);
		return acc + mult * wsi4.cam.nest2.netVolumeNestedParts(twoDimRep, nd, areaThreshold);
	}, 0.);
	return partNetVolume / sheetVolume;
}

/**
 * Assumption:  Seaching Brep of a *component* assembly, so the first assembly providing a valid brep is picked.
 */
function findAssemblyWithBrep(assembly: Assembly): Assembly|undefined {
	const asms = [
		assembly,
		...wsi4.geo.assembly.recursiveSubAssemblies(assembly),
	];
	return asms.find(asm => wsi4.geo.assembly.brep(asm) !== undefined);
}

// Color red as rgb
const red: Vector3 = {
	entries: [
		1,
		0,
		0,
	],
};

/**
 * Creates new assembly where colored faces indicate problems
 *
 * Note:  Geometry is considered problematic if it does not fit the sheet heuristics for a part.
 */
export function problematicGeometryAsm(vertex: Vertex): Assembly {
	assertDebug(
		() => wsi4.node.hasProblematicGeometries(vertex),
		"Pre-condition violated:  Node has no problematic geometries",
	);

	const entities = extractGeometryErrorEntities(vertex);
	assert(entities.length > 0, "Expected array with at least one geometric entity");

	const topLevelAsm = wsi4.node.inputAssembly(vertex);
	assert(topLevelAsm !== undefined, "Vertex must possess an assembly");

	const asmWithBrep = findAssemblyWithBrep(topLevelAsm);
	assert(asmWithBrep !== undefined, "Expecting assembly with brep");

	return wsi4.geo.assembly.setEntityColors(asmWithBrep, entities, red);
}

export function tubeProfileForVertex(vertex: Vertex, tubeProfilesInput?: readonly Readonly<TubeProfile>[]): TubeProfile | undefined {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.tubeCutting, "Pre-condition violated");

	const profileGeometry = wsi4.node.tubeProfileGeometry(vertex);
	if (profileGeometry === undefined) {
		return undefined;
	}

	return tubeProfileForGeometry(profileGeometry, tubeProfilesInput);
}

export function getTubeMaterialId(vertex: Vertex): string|undefined {
	const entries = collectNodeUserDataEntries("tubeMaterial", vertex, userDataAccess.article | userDataAccess.reachable);
	assert(entries.length <= 1, "Expecting at most one matching entry");
	return entries.shift()?.identifier;
}

export function getTubeMaterialIdOrThrow(vertex: Vertex): string {
	const result = getTubeMaterialId(vertex);
	assert(result !== undefined, "Expecting valid tube material id");
	return result;
}

export function getTubeSpecificationId(vertex: Vertex): string|undefined {
	const entries = collectNodeUserDataEntries("tubeSpecification", vertex, userDataAccess.article | userDataAccess.reachable);
	assert(entries.length <= 1, "Expecting at most one matching entry");
	return entries.shift()?.identifier;
}

export function getTubeSpecificationIdOrThrow(vertex: Vertex): string {
	const result = getTubeSpecificationId(vertex);
	assert(result !== undefined, "Expecting valid tube specification id");
	return result;
}

export function tubeForVertex(vertex: Vertex): Tube|undefined {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.tubeCutting, "Pre-condition violated");

	const profile = tubeProfileForVertex(vertex);
	if (profile === undefined) {
		return undefined;
	}

	const materialId = getTubeMaterialId(vertex);
	if (materialId === undefined) {
		return undefined;
	}

	const specificationId = getTubeSpecificationId(vertex);
	if (specificationId === undefined) {
		return undefined;
	}

	const tubeStockTable = getTable(TableType.tubeStock);
	const isInStock = (tube: Tube) => {
		const stockRow = tubeStockTable.find(row => row.tubeId === tube.identifier);
		return stockRow === undefined || stockRow.count > 0;
	};

	const clampingLength = getSettingOrDefault("tubeClampingLength");
	const minLength = wsi4.node.profileExtrusionLength(vertex) + clampingLength;
	return getTable(TableType.tube)
		.filter(row => row.tubeProfileId === profile.identifier
				&& row.tubeMaterialId === materialId
				&& row.tubeSpecificationId === specificationId
				&& row.dimX >= minLength
				&& isInStock(row)
		)
		.sort((lhs, rhs) => rhs.dimX - lhs.dimX)
		.shift();
}

/**
 * Compute the number of parts that can be trivially nested on a given tube.
 */
function computeNumPartsPerTube(tube: Readonly<Tube>, extrusionLength: number): number | undefined {
	const nestingDistance = getSettingOrDefault("tubeNestingDistance");
	const clampingLength = getSettingOrDefault("tubeClampingLength");
	const tubeNetLength = Math.max(0, tube.dimX - clampingLength);

	const lengthPerPart = extrusionLength + nestingDistance;
	if (tubeNetLength - extrusionLength <= 0) {
		return undefined;
	}

	return 1 + Math.floor((tubeNetLength - extrusionLength) / lengthPerPart);
}

/**
 * For a tubeCutting node compute the actual tube consumption.
 *
 * The result entails the scrap.
 *
 * In combination with netTubeConsumptionForVertex() the scrap can be computed.
 */
export function grossTubeConsumptionForVertex(vertex: Vertex, tubeInput?: Readonly<Tube>): number | undefined {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.tubeCutting, "Pre-condition violated");

	const tube = tubeInput ?? tubeForVertex(vertex);
	if (tube === undefined) {
		return undefined;
	}

	const partsPerTube = computeNumPartsPerTube(tube, wsi4.node.profileExtrusionLength(vertex));
	if (partsPerTube === undefined) {
		return undefined;
	}

	const numParts = wsi4.node.multiplicity(vertex);
	return numParts / partsPerTube;
}
