import {
	assumeGraphAxioms,
} from "qrc:/js/lib/axioms";
import {
	Times,
} from "qrc:/js/lib/calc_times";
import {
	computeLaserSheetCuttingUnitTimePerPiece,
	LaserSheetCuttingCalcParams,
} from "qrc:/js/lib/calc_laser_sheet_cutting";
import {
	computeTappingUnitTimePerPiece,
	TappingTimeCalcParams,
} from "qrc:/js/lib/calc_times_tapping";
import {
	ProcessType,
	TableType,
	WorkStepType,
} from "qrc:/js/lib/generated/enum";
import {
	computeCompleteSingleSheetMass,
	computeDieSharingPartitions,
	computeNodeMass,
	getAssociatedSheetMaterialId,
	lookUpBendTimeParams,
} from "qrc:/js/lib/graph_utils";
import {
	engravingSegmentsForVertex,
	grossTubeConsumptionForVertex,
	mappedSheetCuttingMaterialId,
	nodeLaserSheetCuttingGasId,
	sheetBendingWsNumBendLines,
	tubeForVertex,
} from "qrc:/js/lib/node_utils";
import {
	getSettingOrDefault,
} from "qrc:/js/lib/settings_table";
import {
	getTable,
	lookUpAutomaticDeburringParams,
	lookUpProcessSetupTimeFallBack,
	lookUpProcessUnitTimeFallBack,
} from "qrc:/js/lib/table_utils";
import {
	assert,
	assertDebug,
	bbDimensionX,
	bbDimensionY,
	computeContourLength,
	getRowForThreshold,
	isEqual,
	isNonNegativeNumber,
} from "qrc:/js/lib/utils";
import {
	getNodeUserDataEntryOrThrow,
} from "qrc:/js/lib/userdata_utils";
import {
	computeTubeCuttingUnitTimePerPiece as computeTubeCuttingUnitTimePerPieceImpl,
	TubeCuttingCalcParams,
} from "qrc:/js/lib/calc_times_tube_cutting";
import {
	tubeCuttingLayerPaths,
} from "qrc:/js/lib/tubecutting_util";

import {
	CalcCache,
} from "./export_calc_cache";

/**
 * Compute net setup time for a die bending node partition
 *
 * Pre-condition:  BendingTimeParameters are the same for all members of the partition
 *
 * For now the representative setup time for a die bending partition is definedby the
 * highest setup time for all members of the partition.
 */
function computeDieBendingPartitionNetSetupTime(vertices: readonly Readonly<Vertex>[]): number {
	assertDebug(() => vertices.length > 0, "Pre-condition violated");
	assertDebug(() => vertices.every(vertex => wsi4.node.workStepType(vertex) === WorkStepType.sheetBending, "Pre-condition violated"));

	// Assumption:  All vertices of the partition share the same `BendTimeParameter`s
	const bendTimeParams = lookUpBendTimeParams(vertices[0]!);

	const sortedBendTimesTable = (() => {
		const table = Array.from(getTable(TableType.bendTime));
		table.sort((lhs, rhs) => (lhs.mass < rhs.mass ? -1 : lhs.mass > rhs.mass ? 1 : 0));
		return table;
	})();

	const allSetupCosts = vertices.map(vertex => {
		// [kg]
		const mass = computeNodeMass(vertex);
		assert(mass !== undefined && isNonNegativeNumber(mass), "Expecting valid mass for die bending node");

		const numBends = sheetBendingWsNumBendLines(vertex);
		assert(numBends !== undefined, "Expecting valid number of bends for die bending node");

		const bendTimes = getRowForThreshold(sortedBendTimesTable, element => element.mass >= mass);
		assert(bendTimes !== undefined, "Expecting valid bend times");

		// [s]
		const setupTimeBase = bendTimeParams.setupTimeDelta * 60 + bendTimeParams.setupTimeFactor * bendTimes.setupTime * 60;

		// [s]
		const setupTimePerBend = bendTimeParams.setupTimePerBendDelta * 60 + bendTimeParams.setupTimePerBendFactor * bendTimes.setupTimePerBend * 60;

		// [s]
		return setupTimeBase + numBends * setupTimePerBend;
	});

	return Math.max(...allSetupCosts);
}

function computeDieBendingUnitTimePerPiece(vertex: Vertex): number {
	// [kg]
	const mass = computeNodeMass(vertex);
	assert(mass !== undefined && isNonNegativeNumber(mass), "Expecting valid mass for die bending node");

	const sortedBendTimesTable = (() => {
		const table = Array.from(getTable(TableType.bendTime));
		table.sort((lhs, rhs) => (lhs.mass < rhs.mass ? -1 : lhs.mass > rhs.mass ? 1 : 0));
		return table;
	})();

	const bendTimes = getRowForThreshold(sortedBendTimesTable, element => element.mass >= mass);
	assert(bendTimes !== undefined, "Expecting valid bend times");

	const numBends = sheetBendingWsNumBendLines(vertex);
	assert(numBends !== undefined, "Expecting valid number of bends");

	const bendTimeParams = lookUpBendTimeParams(vertex);

	// [s]
	const unitBaseTime = bendTimeParams.unitTimeDelta * 60 + bendTimeParams.unitTimeFactor * bendTimes.unitTime * 60;

	// [s]
	const unitTimePerBend = bendTimeParams.unitTimePerBendDelta * 60 + bendTimeParams.unitTimePerBendFactor * bendTimes.unitTimePerBend * 60;

	// [s]
	return unitBaseTime + numBends * unitTimePerBend;
}

function computeDieBendingSetupTime(vertex: Vertex, calcCache?: CalcCache): number {
	const partition = (() => {
		const partitions = (() => {
			const computeParts = () => computeDieSharingPartitions(wsi4.graph.vertices()
				.filter(v => wsi4.node.processType(v) === ProcessType.dieBending));
			if (calcCache === undefined) {
				return computeParts();
			} else {
				if (calcCache.dieSharingPartitions === undefined) {
					calcCache.dieSharingPartitions = computeParts();
				}
				return calcCache.dieSharingPartitions;
			}
		})();
		const result = partitions.find(p => p.some(v => isEqual(v, vertex)));
		assert(result !== undefined, "Expecting one matching partition");
		return result;
	})();

	// [s]
	const partitionNetSetupTime = computeDieBendingPartitionNetSetupTime(partition);
	return partitionNetSetupTime / partition.length;
}

export function computeLaserSheetCuttingCalcParams(vertex: Vertex): LaserSheetCuttingCalcParams {
	const processType = wsi4.node.processType(vertex);
	assert(processType === ProcessType.laserSheetCutting, "Expected ProcessType: " + ProcessType.laserSheetCutting + ";  actual ProcessType: " + processType);

	const thickness = wsi4.node.sheetThickness(vertex);
	assert(thickness !== undefined, "Thickness invalid");

	const twoDimRep = wsi4.node.twoDimRep(vertex);
	assert(twoDimRep !== undefined, "TwoDimRep invalid");

	const sheetMaterialId = getAssociatedSheetMaterialId(vertex);
	const sheetCuttingMaterialId = mappedSheetCuttingMaterialId(sheetMaterialId);
	assert(sheetCuttingMaterialId !== undefined, "Expecting valid laser sheet cutting material");

	const laserSheetCuttingGasId = nodeLaserSheetCuttingGasId(vertex);
	assert(laserSheetCuttingGasId !== undefined, "Expecting valid laser sheet cutting gas");

	const engravings = engravingSegmentsForVertex(vertex);

	return {
		twoDimRep: twoDimRep,
		thickness: thickness,
		sheetCuttingMaterialId: sheetCuttingMaterialId,
		laserSheetCuttingGasId: laserSheetCuttingGasId,
		engravings: engravings,
	};
}

/**
 * Calculate the Times associated to the packaging done in the vertex
 *
 * @param vertex A packaging vertex for which we want to calculate the Times
 * @returns If all valid data is present in vertex' workstep, the Times associated to it, otherwise undefined
 */
export function packagingWsTimes(vertex: Vertex): Times {
	const packagingContainerWeightsOption = wsi4.node.packagingContainerWeights(vertex);
	if (packagingContainerWeightsOption === undefined) {
		return wsi4.throwError("Missing packaging weights");
	}
	const packagingContainerWeights = packagingContainerWeightsOption;

	// Note: this value can be 0 if 3D nesting fails.
	const count = packagingContainerWeights.length;
	let numberOfArticles = 0;
	for (const s of wsi4.graph.sources(vertex)) {
		numberOfArticles += wsi4.node.multiplicity(s);
	}
	assert(numberOfArticles > 0, "Expecting numberOfArticles to be > 0");

	const packagingUniqueMembers = getNodeUserDataEntryOrThrow("packaging", vertex);
	const packaging = getTable(TableType.packaging)
		.find(row => row.identifier === packagingUniqueMembers.identifier);
	assert(packaging !== undefined, "Expecting valid packaging");

	if (typeof packaging.tep !== "number") {
		return wsi4.throwError("Key \"tep\" missing.");
	}
	if (typeof packaging.tea !== "number") {
		return wsi4.throwError("Key \"tea\" missing.");
	}
	if (typeof packaging.tr !== "number") {
		return wsi4.throwError("Key \"tr\" missing.");
	}
	let unitTime = 0;
	let setupTime = 0;

	// [s]
	unitTime += count * packaging.tep * 60;
	// [s]
	unitTime += numberOfArticles * packaging.tea * 60;
	// [s]
	setupTime = count * packaging.tr * 60;
	return new Times(setupTime, unitTime);
}

/**
 * Compute approx. automatic deburring net length
 *
 * Assumption: All parts are deburred in the same orientation (180° rotations are ok).
 * Function checks whether the parts should be deburred length- or width-wise.
 */
export function computeAutomaticMechanicalDeburringLength(twoDimRep: TwoDimRepresentation, multiplicity: number, sheetMaterialId: string): number {
	const autoDeburringParams = lookUpAutomaticDeburringParams(sheetMaterialId);
	const dim = (() => {
		assumeGraphAxioms([ "deburringSubProcessNodeHasTwoDimRep" ]);
		const bbox = wsi4.cam.util.boundingBox2(twoDimRep);
		return {
			x: bbDimensionX(bbox),
			y: bbDimensionY(bbox),
		};
	})();

	// If maxLenght is too small then there should be a warning due to a violated process dimension constraint.
	const maxLength = Math.max(autoDeburringParams.maxDimY, Math.min(dim.x, dim.y));
	if (maxLength !== autoDeburringParams.maxDimY) {
		wsi4.util.warn("computeAutomaticMechanicalDeburringLength(): Part exceeds machine dimensions.  Increasing machine machine's dimY.");
	}

	const countX = Math.min(multiplicity, Math.floor(maxLength / dim.x));
	const countY = Math.min(multiplicity, Math.floor(maxLength / dim.y));
	assert(countX > 0 || countY > 0);
	// If part's x-axis should be parallel to machine's y-axis
	const flipPart = countX * dim.x > countY * dim.y;
	// [mm]
	return flipPart ? Math.ceil(multiplicity / countX) * dim.y : Math.ceil(multiplicity / countY) * dim.x;
}

function computeAutomaticMechanicalDeburringUnitTime(twoDimRep: TwoDimRepresentation, deburrDoubleSided: boolean, multiplicity: number, sheetMaterialId: string): number {
	// [mm]
	const effectiveLength = computeAutomaticMechanicalDeburringLength(twoDimRep, multiplicity, sheetMaterialId);
	const autoDeburringParams = lookUpAutomaticDeburringParams(sheetMaterialId);
	const doubleSidedFactor = deburrDoubleSided ? 2 : 1;
	// [mm/s]
	const speed = autoDeburringParams.speed * 1000 / 60;
	// [s]
	const unitTimeBase = multiplicity * autoDeburringParams.unitTimeBase * 60;
	return doubleSidedFactor * (unitTimeBase + effectiveLength / speed);
}

export function computeAutomaticMechanicalDeburringTimes(twoDimRep: TwoDimRepresentation, deburrDoubleSided: boolean, multiplicity: number, sheetMaterialId: string, processId: string): Times {
	const unitTime = computeAutomaticMechanicalDeburringUnitTime(
		twoDimRep,
		deburrDoubleSided,
		multiplicity,
		sheetMaterialId,
	);
	const setupTime = lookUpProcessSetupTimeFallBack(processId);
	return new Times(
		setupTime ?? 0,
		unitTime,
	);
}

function automaticMechanicalDeburringUnitTime(vertex: Vertex, multiplicity: number): number {
	assumeGraphAxioms([ "deburringSubProcessNodeHasTwoDimRep" ]);
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return wsi4.throwError("Expecting valid twoDimRep");
	}
	return computeAutomaticMechanicalDeburringUnitTime(
		twoDimRep,
		getNodeUserDataEntryOrThrow("deburrDoubleSided", vertex),
		multiplicity,
		getAssociatedSheetMaterialId(vertex),
	);
}

function computeManualMechanicalDeburringUnitTimePerPiece(twoDimRep: TwoDimRepresentation, deburrDoubleSided: boolean): number {
	const doubleSidedFactor = deburrDoubleSided ? 2 : 1;
	// [mm]
	const contourLength = computeContourLength(twoDimRep);
	const speed = getSettingOrDefault("manualMechanicalDeburringSpeed") * 1000 / 60;
	// [s]
	return doubleSidedFactor * contourLength / speed;
}

export function computeManualMechanicalDeburringTimes(twoDimRep: TwoDimRepresentation, deburrDoubleSided: boolean, multiplicity: number, processId: string): Times {
	const unitTime = multiplicity * computeManualMechanicalDeburringUnitTimePerPiece(twoDimRep, deburrDoubleSided);
	const setupTime = lookUpProcessSetupTimeFallBack(processId);
	return new Times(
		setupTime ?? 0,
		unitTime,
	);
}

function manualMechanicalDeburringUnitTimePerPiece(vertex: Vertex): number {
	assumeGraphAxioms([ "deburringSubProcessNodeHasTwoDimRep" ]);
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return wsi4.throwError("Expecting twoDimRep");
	}
	return computeManualMechanicalDeburringUnitTimePerPiece(twoDimRep, getNodeUserDataEntryOrThrow("deburrDoubleSided", vertex));
}

function userDefinedMachiningSubProcessUnitTimePerPiece(vertex: Vertex): number {
	const type = wsi4.node.processType(vertex);
	const process = getTable(TableType.process)
		.find(row => row.type === type);
	if (process === undefined) {
		return 0;
	}
	const unitTimeRow = getTable(TableType.processUnitTimeFallback)
		.find(row => row.processId === process.identifier);
	const numOperations = (() => {
		if (type === ProcessType.userDefinedThreading) {
			return getNodeUserDataEntryOrThrow("numThreads", vertex);
		} else if (type === ProcessType.userDefinedCountersinking) {
			return getNodeUserDataEntryOrThrow("numCountersinks", vertex);
		} else {
			return wsi4.throwError("Unexpected process type: " + type);
		}
	})();
	// [s]
	return unitTimeRow === undefined ? 0 : numOperations * unitTimeRow.time * 60;
}

function userDefinedThreadingUnitTimePerPiece(vertex: Vertex): number {
	return userDefinedMachiningSubProcessUnitTimePerPiece(vertex);
}

function userDefinedCountersinkingUnitTimePerPiece(vertex: Vertex): number {
	return userDefinedMachiningSubProcessUnitTimePerPiece(vertex);
}

function slideGrindingUnitTimePerPiece(vertex: Vertex): number {
	const processId = wsi4.node.processId(vertex);
	const unitTimeRow = getTable(TableType.processUnitTimeFallback)
		.find(row => row.processId === processId);
	// [s]
	return unitTimeRow === undefined ? 0 : unitTimeRow.time * 60;
}

function sheetTappingUnitTimePerPiece(vertex: Vertex): number {
	const thickness = wsi4.node.sheetThickness(vertex);
	assert(thickness !== undefined, "Expecting valid thickness");

	const tappingDataEntries = getNodeUserDataEntryOrThrow("sheetTappingData", vertex)
		.map(entry => ({
			screwThreadId: entry.screwThread.identifier,
			depth: thickness,
		}));

	const params: TappingTimeCalcParams = {
		tappingData: tappingDataEntries,
		processId: wsi4.node.processId(vertex),
	};
	return computeTappingUnitTimePerPiece(params);
}

function computeSheetUnitTime(vertex: Vertex): number {
	const twoDimRep = wsi4.node.twoDimRep(vertex);
	if (twoDimRep === undefined) {
		return 0;
	} else {
		const unitTimePerSheet = lookUpUnitTime(vertex, 1);
		const numSheets = wsi4.cam.nest2.nestingDescriptors(twoDimRep)
			.reduce((acc, nestingDescriptor) => acc + wsi4.cam.nest2.nestingMultiplicity(twoDimRep, nestingDescriptor), 0);
		return unitTimePerSheet === undefined ? 0 : numSheets * unitTimePerSheet;
	}
}

/**
 * Compute unit time for a tube vertex
 *
 * Due to the special multiplicity semantics for semimanufactured WSTs this function always
 * considers the actual multiplicity, i.e. no scaled multiplicity.  Scale values based on
 * the result of this function need to apply scaling on the result instead of tweaking the
 * input multiplicity.
 */
function computeTubeUnitTime(tubeVertex: Vertex): number | undefined {
	assertDebug(
		() => wsi4.node.workStepType(tubeVertex) === WorkStepType.tube,
		"Pre-condition violated",
	);

	// [s]
	const unitTimePerTube = lookUpUnitTime(tubeVertex, 1);
	if (unitTimePerTube === undefined || unitTimePerTube === 0) {
		return 0;
	}

	const tubeCuttingVertex = (() => {
		const vertices = wsi4.graph.reachable(tubeVertex)
			.filter(vertex => wsi4.node.workStepType(vertex) === WorkStepType.tubeCutting);
		assert(vertices.length === 1, "Expecting one reachable tube cutting vertex");
		return vertices[0]!;
	})();

	const tubeConsumption = grossTubeConsumptionForVertex(tubeCuttingVertex);
	if (tubeConsumption === undefined) {
		return undefined;
	}

	const numTubes = Math.ceil(tubeConsumption);
	assert(numTubes >= 1, "Expecting at least one tube to be required");

	return numTubes * unitTimePerTube;
}

function computeTubeCuttingUnitTimePerPiece(vertex: Vertex): number | undefined {
	const layered = wsi4.node.layered(vertex);
	assert(layered !== undefined, "Expecting valid layered for node of type tubeCutting");

	const cuttingPaths = [
		...tubeCuttingLayerPaths("cuttingOuterContour", layered),
		...tubeCuttingLayerPaths("cuttingInnerContour", layered),
	];

	// Not supported as of now
	const engravingPaths: Segment[][] = [];

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

	const processId = wsi4.node.processId(vertex);
	const mapping = getTable(TableType.tubeCuttingProcessMapping)
		.find(row => row.tubeMaterialId === tube.tubeMaterialId && row.processId === processId);
	assert(mapping !== undefined, "Expecting valid TubeCuttingProcessMapping at this point");

	const thickness = wsi4.node.sheetThickness(vertex);
	assert(thickness !== undefined, "Expecting valid sheet thickness for node of type tube cutting");

	const params: TubeCuttingCalcParams = {
		cuttingPaths: cuttingPaths,
		engravingPaths: engravingPaths,
		thickness: thickness,
		tubeCuttingProcessId: mapping.tubeCuttingProcessId,
	};

	const unitTimePerPiece = computeTubeCuttingUnitTimePerPieceImpl(params);
	if (unitTimePerPiece === undefined) {
		return undefined;
	}

	return unitTimePerPiece;
}

/**
 * Look for setup time in fallback value table
 *
 * @returns Setup time [s] from table (if any)
 */
function lookUpSetupTime(vertex: Vertex): number|undefined {
	const processId = wsi4.node.processId(vertex);
	return lookUpProcessSetupTimeFallBack(processId);
}

/**
 * Look for unit time in fallback value table
 *
 * @returns Unit time [s] from table (if any)
 */
function lookUpUnitTime(vertex: Vertex, multiplicity: number): number|undefined {
	const processId = wsi4.node.processId(vertex);
	const unitTimePerPiece = lookUpProcessUnitTimeFallBack(processId);
	return unitTimePerPiece === undefined ? undefined : multiplicity * unitTimePerPiece;
}

export function getFixedMultiplicity(vertex: Vertex): number {
	switch (wsi4.node.workStepType(vertex)) {
		case WorkStepType.packaging:
		case WorkStepType.tube:
		case WorkStepType.sheet: return 1;
		case WorkStepType.sheetCutting:
		case WorkStepType.sheetBending:
		case WorkStepType.joining:
		case WorkStepType.userDefined:
		case WorkStepType.userDefinedBase:
		case WorkStepType.tubeCutting:
		case WorkStepType.transform:
		case WorkStepType.undefined: return wsi4.node.multiplicity(vertex);
	}
}

function computeNodeSetupTime(
	vertex: Vertex,
	userData: StringIndexedInterface,
	computeHandlingTime: () => Times,
	calcCache?: CalcCache,
): number {
	const computeSetupTime = (specificSetupTime?: () => number): number => {
		const userDataValue = getNodeUserDataEntryOrThrow("setupTime", vertex, userData);
		if (userDataValue !== undefined) {
			return userDataValue;
		} else if (specificSetupTime !== undefined) {
			const handlingTime = computeHandlingTime();
			return handlingTime.setup + specificSetupTime();
		} else {
			const handlingTime = computeHandlingTime();
			return handlingTime.setup + (lookUpSetupTime(vertex) ?? 0);
		}
	};

	switch (wsi4.node.workStepType(vertex)) {
		case WorkStepType.sheetBending: {
			const processType = wsi4.node.processType(vertex);
			if (processType === ProcessType.dieBending) {
				return computeSetupTime(() => computeDieBendingSetupTime(vertex, calcCache));
			} else {
				return computeSetupTime();
			}
		}
		case WorkStepType.packaging: {
			const times = packagingWsTimes(vertex);
			return computeSetupTime(() => times.setup);
		}
		case WorkStepType.sheetCutting:
		case WorkStepType.joining:
		case WorkStepType.userDefined:
		case WorkStepType.userDefinedBase:
		case WorkStepType.sheet:
		case WorkStepType.tubeCutting:
		case WorkStepType.transform:
		case WorkStepType.tube:
		case WorkStepType.undefined: {
			return computeSetupTime();
		}
	}
}

/**
 * Compute times for a node
 *
 * @returns setup- and unit-time for the node
 *
 * Note: Tables are expected to be consistent
 */
function computeNodeUnitTime(
	vertex: Vertex,
	multiplicityInput: number|undefined,
	userData: StringIndexedInterface,
	computeHandlingTime: () => Times,
): number | undefined {
	const multiplicity = multiplicityInput ?? getFixedMultiplicity(vertex);

	const computeUnitTime = (specificOverallUnitTime?: () => number | undefined): number | undefined => {
		const userDataValue = getNodeUserDataEntryOrThrow("unitTimePerPiece", vertex, userData);
		if (userDataValue !== undefined) {
			return multiplicity * userDataValue;
		} else if (specificOverallUnitTime !== undefined) {
			const specificTime = specificOverallUnitTime();
			if (specificTime === undefined) {
				return undefined;
			}
			const handlingTime = computeHandlingTime();
			return handlingTime.unit + specificTime;
		} else {
			const handlingTime = computeHandlingTime();
			return handlingTime.unit + (lookUpUnitTime(vertex, multiplicity) ?? 0);
		}
	};

	switch (wsi4.node.workStepType(vertex)) {
		case WorkStepType.sheetCutting: {
			const processType = wsi4.node.processType(vertex);
			if (processType === ProcessType.laserSheetCutting) {
				return computeUnitTime(() => {
					const params = computeLaserSheetCuttingCalcParams(vertex);
					return multiplicity * computeLaserSheetCuttingUnitTimePerPiece(params);
				});
			} else {
				return computeUnitTime();
			}
		}
		case WorkStepType.sheetBending: {
			const processType = wsi4.node.processType(vertex);
			if (processType === ProcessType.dieBending) {
				return computeUnitTime(() => multiplicity * computeDieBendingUnitTimePerPiece(vertex));
			} else {
				return computeUnitTime();
			}
		}
		case WorkStepType.tubeCutting: {
			const unitTimePerPiece = computeTubeCuttingUnitTimePerPiece(vertex);
			if (unitTimePerPiece === undefined) {
				return undefined;
			}
			return computeUnitTime(() => multiplicity * unitTimePerPiece);
		}
		case WorkStepType.userDefined: {
			const processType = wsi4.node.processType(vertex);
			if (processType === ProcessType.automaticMechanicalDeburring) {
				return computeUnitTime(() => automaticMechanicalDeburringUnitTime(vertex, multiplicity));
			} else if (processType === ProcessType.manualMechanicalDeburring) {
				return computeUnitTime(() => multiplicity * manualMechanicalDeburringUnitTimePerPiece(vertex));
			} else if (processType === ProcessType.userDefinedThreading) {
				return computeUnitTime(() => multiplicity * userDefinedThreadingUnitTimePerPiece(vertex));
			} else if (processType === ProcessType.userDefinedCountersinking) {
				return computeUnitTime(() => multiplicity * userDefinedCountersinkingUnitTimePerPiece(vertex));
			} else if (processType === ProcessType.slideGrinding) {
				return computeUnitTime(() => multiplicity * slideGrindingUnitTimePerPiece(vertex));
			} else if (processType === ProcessType.sheetTapping) {
				return computeUnitTime(() => multiplicity * sheetTappingUnitTimePerPiece(vertex));
			} else {
				return computeUnitTime();
			}
		}
		case WorkStepType.packaging: {
			assert(
				!wsi4.util.isDebug() || multiplicity === undefined || multiplicity === 1,
				"Expecting multiplicity = 1 for node of type packaging;  actual value = " + multiplicity.toString(),
			);
			const times = packagingWsTimes(vertex);
			return computeUnitTime(() => times.unit);
		}
		case WorkStepType.sheet: {
			return computeUnitTime(() => computeSheetUnitTime(vertex));
		}
		case WorkStepType.tube: {
			assertDebug(
				() => multiplicity === undefined || multiplicity === 1,
				"Expecting multiplicity = 1 for node of type tube;  actual value = " + multiplicity.toString(),
			);
			return computeUnitTime(() => computeTubeUnitTime(vertex));
		}
		case WorkStepType.joining:
		case WorkStepType.userDefinedBase:
		case WorkStepType.transform:
		case WorkStepType.undefined: {
			return computeUnitTime();
		}
	}
}

function computeNodeHandlingTime(vertex: Vertex, multiplicityInput?: number): Times {
	const multiplicity = (() => {
		if (wsi4.node.workStepType(vertex) === WorkStepType.sheet) {
			assert(
				multiplicityInput === undefined || multiplicityInput === 1,
				"Expecting multiplicity 1 for sheet node",
			);
			const twoDimRep = wsi4.node.twoDimRep(vertex);
			if (twoDimRep === undefined) {
				return 0;
			} else {
				return wsi4.cam.nest2.nestingDescriptors(twoDimRep)
					.reduce((acc, nestingDescriptor) => acc + wsi4.cam.nest2.nestingMultiplicity(twoDimRep, nestingDescriptor), 0);
			}
		} else {
			return multiplicityInput ?? getFixedMultiplicity(vertex);
		}
	})();

	const mass = (() => {
		if (wsi4.node.workStepType(vertex) === WorkStepType.sheet) {
			return computeCompleteSingleSheetMass(vertex);
		} else {
			return computeNodeMass(vertex) ?? 0;
		}
	})();

	const processId = wsi4.node.processId(vertex);
	const processHandlingTimes = getTable(TableType.processHandlingTime)
		.filter(row => row.processId === processId)
		.filter(row => row.mass < mass)
		.sort((lhs, rhs) => rhs.mass - lhs.mass);

	if (processHandlingTimes.length === 0) {
		return new Times(0, 0);
	} else {
		return new Times(
			// [s]
			60 * processHandlingTimes[0]!.setupTimeDelta,
			// [s]
			60 * processHandlingTimes[0]!.unitTimeDelta * multiplicity,
		);
	}
}

function computeNodeTimesImpl(vertex: Vertex, multiplicityInput?: number, calcCache?: CalcCache): Times | undefined {
	let cachedHandlingTime: Times|undefined = undefined;
	const computeHandlingTime = (): Times => {
		if (cachedHandlingTime === undefined) {
			cachedHandlingTime = computeNodeHandlingTime(vertex, multiplicityInput);
		}
		return cachedHandlingTime;
	};

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

	const setupTime = computeNodeSetupTime(
		vertex,
		userData,
		computeHandlingTime,
		calcCache,
	);
	if (setupTime === undefined) {
		return undefined;
	}

	const unitTime = computeNodeUnitTime(
		vertex,
		multiplicityInput,
		userData,
		computeHandlingTime,
	);
	if (unitTime === undefined) {
		return undefined;
	}

	return new Times(setupTime, unitTime);
}

/**
 * Compute times for a node
 *
 * @returns setup- and unit-time for the node
 *
 * Note: Tables are expected to be consistent
 */
export function computeNodeTimes(vertex: Vertex, multiplicityInput?: number, cache?: CalcCache): Times | undefined {
	if (cache === undefined) {
		return computeNodeTimesImpl(vertex, multiplicityInput);
	} else {
		const multOneTimes = ((): Times | undefined => {
			const entry = cache.nodeTimesEntries.find(entry => isEqual(entry.vertex, vertex));
			if (entry === undefined) {
				const multOneTimes = computeNodeTimesImpl(vertex, 1, cache);
				cache.nodeTimesEntries.push({
					vertex: vertex,
					multOneTimes: multOneTimes,
				});
				return multOneTimes;
			} else {
				return entry.multOneTimes;
			}
		})();
		if (multOneTimes === undefined) {
			return undefined;
		}

		const multiplicity = multiplicityInput ?? getFixedMultiplicity(vertex);
		return new Times(
			multOneTimes.setup,
			multiplicity * multOneTimes.unit,
		);
	}
}
