import {
	addCosts,
	Costs,
} from "qrc:/js/lib/calc_costs";
import {
	ProcessType,
	TableType,
	WorkStepType,
} from "qrc:/js/lib/generated/enum";
import {
	computeSheetConsumption,
	hasSheetSourceArticle,
	hasTubeSourceArticle,
	isComponentArticle,
	isJoiningArticle,
	isSemimanufacturedArticle,
	lookUpBendRateParams,
	netTubeConsumptionForVertex,
	sheetIdForVertex,
} from "qrc:/js/lib/graph_utils";
import {
	computeSheetConsumptionNestedParts,
	isTestReportRequiredForSheet,
	tubeForVertex,
	grossTubeConsumptionForVertex,
} from "qrc:/js/lib/node_utils";
import {
	getSettingOrDefault,
} from "qrc:/js/lib/settings_table";
import {
	computeTubeVolume,
	getTable,
} from "qrc:/js/lib/table_utils";
import {
	hasManufacturingCostOverride,
	nodeUserDatum,
	nodeUserDatumOrDefault,
} from "qrc:/js/lib/userdata_utils";
import {
	assert,
	assertDebug,
	isEqual,
	isNumber,
} from "qrc:/js/lib/utils";
import {
	laserSheetCuttingRatePerSecond,
} from "qrc:/js/lib/calc_laser_sheet_cutting";

import {
	back,
	front,
} from "qrc:/js/lib/array_util";
import {
	CalcSettings,
} from "qrc:/js/lib/calc_settings";
import {
	computeLaserSheetCuttingCalcParams,
	computeNodeTimes,
	getFixedMultiplicity,
} from "./export_calc_times";
import {
	CalcCache,
} from "./export_calc_cache";

/**
 * Compute relative share for an article based on its source article and all other targets of this source article
 *
 * Precondition: the article has one source article and this source article consists of at least one sheet node.
 */
export function computeSheetSourceShare(article: readonly Readonly<Vertex>[]): number {
	assertDebug(() => hasSheetSourceArticle(article), "Pre-condition violated: " + article.map(v => wsi4.node.workStepType(v)).join(", "));
	return wsi4.graph.semimanufacturedSourceShare(front(article));
}

/**
 * For now tubes cutting nodes are nested separately.
 * Hence, the source share is always 1.
 */
export function computeTubeSourceShare(article: readonly Readonly<Vertex>[], _cache?: CalcCache): number|undefined {
	assertDebug(() => isComponentArticle(article) && hasTubeSourceArticle(article), "Pre-condition violated");
	return 1.;
}

/**
 * Compute sheet consumption for an article
 *
 * Assumption:  The underlying nesting is utilizes exactly one sheet format
 */
export function computeArticleSheetConsumption(article: readonly Readonly<Vertex>[]): number|undefined {
	const sheetVertex = [
		...article,
		...wsi4.graph.reaching(front(article)),
	].find(vertex => wsi4.node.processType(vertex) === ProcessType.sheet);
	if (sheetVertex === undefined || wsi4.node.twoDimRep(sheetVertex) === undefined) {
		return undefined;
	}

	const sheetConsumption = computeSheetConsumption(sheetVertex);
	if (sheetConsumption === undefined) {
		return undefined;
	}

	if (hasSheetSourceArticle(article)) {
		const sheetSourceShare = computeSheetSourceShare(article);
		if (sheetSourceShare === undefined) {
			return undefined;
		} else {
			return sheetSourceShare * sheetConsumption;
		}
	} else {
		return sheetConsumption;
	}
}

function computeRelParam(type: string, surchargeTable: readonly Readonly<Surcharge>[]) {
	return surchargeTable.filter(element => element.type === type)
		.reduce((acc, element) => acc * (1 + element.value), 1);
}

/**
 * Lookup price of a sheet
 *
 * @param sheetIdentifier  identifier of sheet in price table
 * @returns price of sheet [Currency]
 */
function lookupSheetPrice(sheetIdentifier: string, table: readonly Readonly<SheetPrice>[]): number {
	const found = table.find(row => row.sheetId === sheetIdentifier);
	if (found === undefined) {
		return wsi4.throwError("lookupSheetPrice(): sheet price not available");
	}
	if (typeof found.pricePerSheet !== "number") {
		return wsi4.throwError("lookupSheetPrice(): sheet price table invalid");
	}
	return found.pricePerSheet;
}

export function processRatePerSecond(processId: string): number|undefined {
	const table = getTable(TableType.processRate);
	const row = table.find(element => element.processId === processId);

	// [Currency/s]
	return row === undefined ? undefined : row.rate / 3600;
}

function computeNodeMaterialCosts(vertex: Vertex, multiplicityInput: number|undefined, specificComputation: () => number|undefined = () => 0): number | undefined {
	const multiplicity = multiplicityInput ?? getFixedMultiplicity(vertex);
	return (() => {
		const value = nodeUserDatum("userDefinedMaterialCostsPerPiece", vertex);
		if (value !== undefined) {
			return multiplicity * value;
		} else {
			return undefined;
		}
	})() ?? specificComputation();
}

function genericNodeCosts(
	vertex: Vertex,
	multiplicityInput: number|undefined,
	ratePerSecondInput?: number,
	calcCache?: CalcCache,
) {
	const ratePerSecond = ratePerSecondInput ?? processRatePerSecond(wsi4.node.processId(vertex));
	if (ratePerSecond === undefined) {
		// Not returning undefined as this breaks existing calculations.
		// There is no database constraint for hourly rates to be defined in the processCalclulation table so handling this gracefully here.
		return new Costs(0, 0, 0);
	} else {
		const multiplicity = multiplicityInput ?? getFixedMultiplicity(vertex);
		const materialCosts = computeNodeMaterialCosts(vertex, multiplicity);
		if (materialCosts === undefined) {
			return undefined;
		}

		const times = computeNodeTimes(vertex, multiplicity, calcCache);
		if (times === undefined) {
			return undefined;
		}
		const setupAndUnitCosts: [number, number] = [
			ratePerSecond * times.setup,
			ratePerSecond * times.unit,
		];
		return new Costs(
			materialCosts,
			setupAndUnitCosts[0],
			setupAndUnitCosts[1],
		);
	}

}

export interface SheetConsumptionMasses {
	total: number,
	scrap: number,
}

export function computeSheetConsumptionMasses(
	sheet: Readonly<Sheet>,
	consumptions: Readonly<{
		total: number,
		parts: number,
	}>,
): SheetConsumptionMasses {
	assert(consumptions.parts <= consumptions.total, "Parts sheet consumption cannot exceed total sheet consumption");

	const densities = getTable(TableType.sheetMaterialDensity);
	const densityRow = densities.find(row => row.sheetMaterialId === sheet.sheetMaterialId);
	assert(densityRow !== undefined, "Tables inconsistent");

	// [kg / m³] -> [kg / mm³]
	const density = densityRow.density * 0.001 * 0.001 * 0.001;

	// [mm³]
	const volumePerSheet = sheet.dimX * sheet.dimY * sheet.thickness;

	// [kg]
	const massPerSheet = volumePerSheet * density;

	const scrapFraction = consumptions.total - consumptions.parts;
	return {
		total: consumptions.total * massPerSheet,
		scrap: scrapFraction * massPerSheet,
	};
}

export interface TubeConsumptionMasses {
	gross: number,
	scrap: number,
}

export function computeTubeConsumptionMasses(
	tube: Readonly<Tube>,
	tubeProfileGeometry: Readonly<TubeProfileGeometry>,
	consumptions: Readonly<{
		gross: number,
		net: number,
	}>,
): TubeConsumptionMasses {
	assert(
		consumptions.net <= consumptions.gross,
		"Net consumption cannot exceed gross consumption: "
			+ consumptions.net.toFixed(3)
			+ " vs. "
			+ consumptions.gross.toFixed(3),
	);

	const densities = getTable(TableType.tubeMaterialDensity);
	const densityRow = densities.find(row => row.tubeMaterialId === tube.tubeMaterialId);
	assert(densityRow !== undefined, "Tables inconsistent");

	// [kg / m³] -> [kg / mm³]
	const density = densityRow.density * 0.001 * 0.001 * 0.001;

	// [mm³]
	const volumePerTube = computeTubeVolume(tube, tubeProfileGeometry);

	// [kg]
	const massPerTube = volumePerTube * density;

	const scrapConsumption = consumptions.gross - consumptions.net;
	return {
		gross: consumptions.gross * massPerTube,
		scrap: scrapConsumption * massPerTube,
	};
}

function sheetWsMaterialCosts(vertex: Vertex): number |undefined {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.sheet, "Pre-condition violated");

	if (wsi4.node.twoDimRep(vertex) === undefined) {
		// Nesting not available
		return undefined;
	}

	const sheetTable = getTable(TableType.sheet);
	const sheetId = sheetIdForVertex(vertex, sheetTable);
	if (sheetId === undefined) {
		return undefined;
	}

	const sheet = sheetTable.find(row => row.identifier === sheetId);
	assert(sheet !== undefined);

	const totalSheetConsumption = computeSheetConsumption(vertex);
	if (totalSheetConsumption === undefined) {
		return undefined;
	}

	const testReportCost = (() => {
		if (isTestReportRequiredForSheet(vertex)) {
			return getSettingOrDefault("sheetTestReportCosts");
		} else {
			return 0;
		}
	})();

	// [Currency]
	const scrapCredit = (() => {
		const sheetMaterialScrapValues = getTable(TableType.sheetMaterialScrapValue);
		const sheetMaterialScrapValue = sheetMaterialScrapValues.find(row => row.sheetMaterialId === sheet.sheetMaterialId);
		if (sheetMaterialScrapValue === undefined) {
			// Valid case since table entry is not mandatory
			return 0;
		}

		// [Currency / kg]
		const scrapValue = sheetMaterialScrapValue.scrapValue;

		const partsSheetConsumption = computeSheetConsumptionNestedParts(vertex);
		assert(partsSheetConsumption !== undefined, "Expecting valid nested part volume fraction at this point");
		assert(partsSheetConsumption <= totalSheetConsumption, "Nested parts volume cannot exceed sheet consumption");

		// [kg]
		const scrapMass = computeSheetConsumptionMasses(
			sheet,
			{
				total: totalSheetConsumption,
				parts: partsSheetConsumption,
			},
		).scrap;

		return scrapMass * scrapValue;
	})();

	const sheetPrices = getTable(TableType.sheetPrice);
	const pricePerSheet = lookupSheetPrice(sheetId, sheetPrices);

	// A misconfigured sheet scrap value could lead to a credit that exceeds the costs for the net consumed sheet.
	return testReportCost + Math.max(0, totalSheetConsumption * pricePerSheet - scrapCredit);
}

function tubeWsMaterialCosts(tubeVertex: Vertex): number | undefined {
	assertDebug(() => wsi4.node.workStepType(tubeVertex) === WorkStepType.tube, "Pre-condition violated");

	const tubeCuttingVertex = (() => {
		const targets = wsi4.graph.targets(tubeVertex);
		assert(targets.length === 1, "Expecting exactly one target vertex for a tube node");
		assertDebug(() => wsi4.node.workStepType(targets[0]!) === WorkStepType.tubeCutting, "Expecting tubeCutting target for tube node");
		return targets[0]!;
	})();

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

	const grossConsumption = grossTubeConsumptionForVertex(tubeCuttingVertex, tube);
	if (grossConsumption === undefined) {
		return undefined;
	}

	const pricePerTube = getTable(TableType.tubePrice)
		.find(row => row.tubeId === tube?.identifier)?.pricePerTube;
	if (pricePerTube === undefined) {
		return undefined;
	}

	// [Currency]
	const scrapCredit = (() => {
		const tubeMaterialScrapValues = getTable(TableType.tubeMaterialScrapValue);
		const tubeMaterialScrapValue = tubeMaterialScrapValues.find(row => row.tubeMaterialId === tube.tubeMaterialId);
		if (tubeMaterialScrapValue === undefined) {
			// Valid case since table entry is not mandatory
			return 0;
		}

		// [Currency / kg]
		const scrapValue = tubeMaterialScrapValue.scrapValue;

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

		const netConsumption = netTubeConsumptionForVertex(tubeCuttingVertex, tube);
		assert(netConsumption !== undefined, "Expecting valid net consumption at this point");
		assert(
			netConsumption <= grossConsumption,
			"Net consumption cannot exceed gross consumption: "
			+ netConsumption.toFixed(3)
			+ " vs. "
			+ grossConsumption.toFixed(3),
		);

		// [kg]
		const scrapMass = computeTubeConsumptionMasses(
			tube,
			profileGeometry,
			{
				gross: grossConsumption,
				net: netConsumption,
			},
		).scrap;

		// [Currency]
		return scrapMass * scrapValue;
	})();

	// [Currency]
	return Math.max(0, grossConsumption * pricePerTube - scrapCredit);
}

function sheetWsCosts(vertex:Vertex, calcCache?: CalcCache): Costs|undefined {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.sheet, "Pre-condition violated");
	const materialCosts = computeNodeMaterialCosts(vertex, undefined, () => sheetWsMaterialCosts(vertex));
	if (materialCosts === undefined) {
		return undefined;
	}

	const multiplicity = 1;
	const times = computeNodeTimes(vertex, multiplicity, calcCache);
	if (times === undefined) {
		return undefined;
	}

	const [
		setupCosts,
		unitCosts,
	] = ((): [number, number] => {
		const ratePerSecond = (() => {
			const r = processRatePerSecond(wsi4.node.processId(vertex));
			// This keeps the calculation backwards compatible for nodes that have not been manually calculatable initially.
			return r === undefined ? 0 : r;
		})();
		return [
			times.setup * ratePerSecond,
			times.unit * ratePerSecond,
		];
	})();
	return new Costs(
		materialCosts,
		setupCosts,
		unitCosts,
	);
}

function tubeWsCosts(vertex: Vertex, calcCache?: CalcCache): Costs|undefined {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.tube, "Pre-condition violated");
	const multiplicity = 1;
	const materialCosts = computeNodeMaterialCosts(vertex, multiplicity, () => tubeWsMaterialCosts(vertex));
	if (materialCosts === undefined) {
		return undefined;
	}

	const times = computeNodeTimes(vertex, multiplicity, calcCache);
	if (times === undefined) {
		return undefined;
	}

	const ratePerSecond = processRatePerSecond(wsi4.node.processId(vertex)) ?? 0;
	return new Costs(
		materialCosts,
		ratePerSecond * times.setup,
		ratePerSecond * times.unit,
	);
}

function userDefinedBaseWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	const ratePerSecond = (() => {
		const r = processRatePerSecond(wsi4.node.processId(vertex));
		return r === undefined ? 0 : r;
	})();
	return genericNodeCosts(
		vertex,
		multiplicity,
		ratePerSecond,
		calcCache,
	);
}

function sheetCuttingWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	const processType = wsi4.node.processType(vertex);
	if (processType === ProcessType.laserSheetCutting) {
		const params = computeLaserSheetCuttingCalcParams(vertex);
		if (params === undefined) {
			return undefined;
		}
		const ratePerSecond = laserSheetCuttingRatePerSecond(params);
		return genericNodeCosts(
			vertex,
			multiplicity,
			ratePerSecond,
			calcCache,
		);
	} else {
		wsi4.util.error("sheetCuttingWsCosts(): Calculation not implemented for process type \"" + processType + "\"");
		return undefined;
	}
}

function computeDieBendingCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	const baseRatePerSecond = processRatePerSecond(wsi4.node.processId(vertex));
	if (isNumber(baseRatePerSecond)) {
		const bendRateParams = lookUpBendRateParams(vertex);
		if (bendRateParams === undefined) {
			return undefined;
		}
		const ratePerSecond = bendRateParams.hourlyRateDelta / 3600 + bendRateParams.hourlyRateFactor * baseRatePerSecond;
		return genericNodeCosts(
			vertex,
			multiplicity,
			ratePerSecond,
			calcCache,
		);
	} else {
		return undefined;
	}
}

function sheetBendingWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	if (wsi4.node.processType(vertex) === ProcessType.dieBending) {
		return computeDieBendingCosts(vertex, multiplicity, calcCache);
	} else {
		return undefined;
	}
}

function userDefinedWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	return genericNodeCosts(vertex, multiplicity, undefined, calcCache);
}

function transformWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	return genericNodeCosts(vertex, multiplicity, undefined, calcCache);
}

function tubeCuttingWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	return genericNodeCosts(vertex, multiplicity, undefined, calcCache);
}

// Unexpected since there is no built-in support for the packaging WorkStep any more
function packagingWsCosts(_vertex: Vertex, _calcCache?: CalcCache): Costs|undefined {
	return undefined;
}

function joiningWsCosts(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache) {
	return genericNodeCosts(vertex, multiplicity, undefined, calcCache);
}

/**
 * Compute manufacturing costs, setup costs and unit costs for a node
 */
function computeNodeCostsImpl(vertex: Vertex, multiplicity?: number, calcCache?: CalcCache): Costs|undefined {
	switch (wsi4.node.workStepType(vertex)) {
		case WorkStepType.sheet: {
			assertDebug(() => multiplicity === undefined || multiplicity === 1,
				"Multiplicity for sheet node must be 1;  actual value = " + (multiplicity === undefined ? "undef" : multiplicity.toString()));
			return sheetWsCosts(vertex, calcCache);
		}
		case WorkStepType.userDefinedBase: {
			return userDefinedBaseWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.sheetCutting: {
			return sheetCuttingWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.sheetBending: {
			return sheetBendingWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.userDefined: {
			return userDefinedWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.joining: {
			return joiningWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.packaging: {
			assertDebug(() => multiplicity === undefined || multiplicity === 1,
				"Multiplicity for packaging node must be 1;  actual value = " + (multiplicity === undefined ? "undef" : multiplicity.toString()));
			return packagingWsCosts(vertex, calcCache);
		}
		case WorkStepType.transform: {
			return transformWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.tubeCutting: {
			return tubeCuttingWsCosts(vertex, multiplicity, calcCache);
		}
		case WorkStepType.tube: {
			assertDebug(() => multiplicity === undefined || multiplicity === 1,
				"Multiplicity for tube node must be 1;  actual value = " + (multiplicity === undefined ? "undef" : multiplicity.toString()));
			return tubeWsCosts(vertex, calcCache);
		}
		case WorkStepType.undefined: return undefined;
	}
}

function computeGlobalFactor(table: readonly Readonly<Surcharge>[]): number {
	return computeRelParam("globalFactor", table);
}

function computeMaterialCostsFactor(table: readonly Readonly<Surcharge>[]): number {
	return computeRelParam("materialCostsFactor", table);
}

function computeProductionOverheadFactor(table: readonly Readonly<Surcharge>[]): number {
	return computeRelParam("productionOverheadFactor", table);
}

interface Price {
	fixComponent : number;
	varComponent : number;
}

export function computeManufacturingPriceExclSurcharges(costs: Costs|undefined|(Costs | undefined)[]): number|undefined {
	costs = Array.isArray(costs) ? costs : [ costs ];
	return costs.some(c => c === undefined) ? undefined : costs.reduce((acc: number, c) => {
		assert(c !== undefined);
		return acc + c.material + c.setup + c.unit;
	}, 0);
}

function computeManufacturingCostsInclSurchargesImpl(
	costs: Costs|undefined|(Costs | undefined)[],
	surchargeTableInput?: readonly Readonly<Surcharge>[],
): Price|undefined {
	const surchargeTable = surchargeTableInput ?? getTable(TableType.surcharge);
	const costsSum = addCosts(Array.isArray(costs) ? costs : [ costs ]);
	if (costsSum === undefined) {
		return undefined;
	} else {
		const prodOverheadFactor = computeProductionOverheadFactor(surchargeTable);
		const materialCostsFactor = computeMaterialCostsFactor(surchargeTable);
		return {
			fixComponent: costsSum.setup * prodOverheadFactor,
			varComponent: costsSum.unit * prodOverheadFactor + costsSum.material * materialCostsFactor,
		};
	}
}

export function computeManufacturingPriceInclSurcharges(
	costs: Costs|undefined|(Costs | undefined)[],
	surchargeTableInput?: readonly Readonly<Surcharge>[],
): number|undefined {
	const price = computeManufacturingCostsInclSurchargesImpl(costs, surchargeTableInput);
	return price === undefined ? undefined : price.fixComponent + price.varComponent;
}

function applyGlobalSurcharges(
	price: Price,
	surchargeTableInput?: readonly Readonly<Surcharge>[],
): Price {
	const surchargeTable = surchargeTableInput ?? getTable(TableType.surcharge);
	const globalFactor = computeGlobalFactor(surchargeTable);
	price.fixComponent *= globalFactor;
	price.varComponent *= globalFactor;
	return price;
}

function computeSellingPriceInclGlobalSurchargesImpl(
	costs: Costs|undefined|(Costs | undefined)[],
	surchargeTable?: readonly Readonly<Surcharge>[],
): Price|undefined {
	const priceExclGlobSurcharges = computeManufacturingCostsInclSurchargesImpl(costs, surchargeTable);
	if (priceExclGlobSurcharges === undefined) {
		return undefined;
	} else {
		return applyGlobalSurcharges(
			priceExclGlobSurcharges,
			surchargeTable,
		);
	}
}

/**
 * Note:  Should only be used by shop-specific scripts as certain calculations are only defined in the shop context.
 */
export function shopOnlyComputeSellingPriceInclGlobalSurcharges(
	costs: Costs|undefined|(Costs | undefined)[],
	surchargeTable?: readonly Readonly<Surcharge>[],
): number|undefined {
	const price = computeSellingPriceInclGlobalSurchargesImpl(costs, surchargeTable);
	return price === undefined ? undefined : price.fixComponent + price.varComponent;
}

/**
 * Note:  Should only be used by erp-export to compute legacy costs
 */
export function erpExportOnlyComputeSellingPriceInclGlobalSurcharges(
	costs: Costs|undefined|(Costs | undefined)[],
	surchargeTable?: readonly Readonly<Surcharge>[],
): number|undefined {
	const price = computeSellingPriceInclGlobalSurchargesImpl(costs, surchargeTable);
	return price === undefined ? undefined : price.fixComponent + price.varComponent;
}

/**
 * Wraps actual node cost computation and provides optional caching
 */
export function computeNodeCosts(vertex: Vertex, multiplicityInput?: number, cache?: CalcCache): Costs|undefined {
	assertDebug(() => multiplicityInput === undefined || !isSemimanufacturedArticle(wsi4.graph.article(vertex)) || multiplicityInput === 1,
		"Multiplicity invalid for semimanufactured article.  Expected: undefined or 1.  Actual: " + (multiplicityInput === undefined ? "undef" : multiplicityInput.toString()));
	if (cache === undefined) {
		return computeNodeCostsImpl(vertex, multiplicityInput);
	} else {
		const multOneCosts = (() => {
			const entry = cache.nodeCostsEntries.find(entry => isEqual(entry.vertex, vertex));
			if (entry === undefined) {
				const actualMult = getFixedMultiplicity(vertex);
				const actualMultCosts = computeNodeCostsImpl(vertex, actualMult, cache);
				const multOneCosts = actualMultCosts === undefined
					? undefined
					: new Costs(
						actualMultCosts.material / actualMult,
						actualMultCosts.setup,
						actualMultCosts.unit / actualMult,
					);
				cache.nodeCostsEntries.push({
					vertex: vertex,
					multOneCosts: multOneCosts,
				});
				return multOneCosts;
			} else {
				return entry.multOneCosts;
			}
		})();
		const multiplicity = multiplicityInput ?? getFixedMultiplicity(vertex);
		return multOneCosts === undefined ? undefined : new Costs(
			multiplicity * multOneCosts.material,
			multOneCosts.setup,
			multiplicity * multOneCosts.unit,
		);
	}
}

/**
 * @returns Sheet costs and share of these costs for the article
 *
 * Pre-condition: article does not contain joining
 * Pre-condition: article does not contain userDefinedBase
 *
 * Expecting three cases:
 * (1) related sheet has two or more targets (0 < share < 1)
 * (2) related sheet is part of the article (0 < share <= 1)
 * (3) related sheet forms the article (share = 0)
 */
function computeSheetSourceShareForArticle(article: readonly Vertex[]): number {
	assertDebug(() => article.every(vertex => wsi4.node.workStepType(vertex) !== WorkStepType.joining), "Pre-condition violated");
	assertDebug(() => article.every(vertex => wsi4.node.workStepType(vertex) !== WorkStepType.userDefinedBase), "Pre-condition violated");

	if (hasSheetSourceArticle(article)) {
		// Case (1)
		return computeSheetSourceShare(article);
	} else {
		// Case (2) + (3)
		assert(article.some(vertex => wsi4.node.workStepType(vertex) === WorkStepType.sheet), "Expecting sheet node in article");
		const sheetVertex = article.find(vertex => wsi4.node.workStepType(vertex) === WorkStepType.sheet);
		assert(sheetVertex !== undefined, "Expecting valid vertex");
		// If article consists of a sheet only then the share is 0 as costs for this sheet are covered by shares of its targets
		return (article.some(vertex => wsi4.node.workStepType(vertex) === WorkStepType.sheetCutting) ? 1 : 0);
	}
}

/**
 * Compute costs share of the underlying sheet node (if any)
 *
 * Note:  If there is no associated sheet node then the resulting costs are 0 respectively.
 */
function computeSheetCostsShare(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Costs|undefined {
	if (isJoiningArticle(article)) {
		return {
			material: 0,
			setup: 0,
			unit: 0,
		};
	} else {
		const sheetVertex = (() => {
			const reaching = wsi4.graph.reaching(back(article));
			assertDebug(() => reaching.reduce((acc, vertex) => acc + (wsi4.node.workStepType(vertex) === WorkStepType.sheet ? 1 : 0), 0) <= 1,
				"Expecting at most one sheet vertex among reaching vertices");
			return reaching.find(vertex => wsi4.node.workStepType(vertex) === WorkStepType.sheet);
		})();

		if (sheetVertex === undefined) {
			return {
				material: 0,
				setup: 0,
				unit: 0,
			};
		} else {
			const relShare = computeSheetSourceShareForArticle(article);
			const costs = computeNodeCosts(sheetVertex, 1, cache);
			if (costs === undefined) {
				return undefined;
			}

			const fraction = scaleValue / wsi4.node.multiplicity(front(article));
			return new Costs(
				relShare * fraction * costs.material,
				relShare * costs.setup,
				relShare * fraction * costs.unit,
			);
		}
	}
}

function computeTubeCostsShare(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Costs | undefined {
	assertDebug(
		() => isComponentArticle(article) && hasTubeSourceArticle(article),
		"Pre-condition violated",
	);

	const tubeVertex = (() => {
		const vertices = wsi4.graph.reaching(front(article))
			.filter(vertex => wsi4.node.workStepType(vertex) === WorkStepType.tube);
		assert(vertices.length === 1, "Expecting exactly one reaching tube node");
		return vertices[0]!;
	})();

	const costs = computeNodeCosts(tubeVertex, 1, cache);
	if (costs === undefined) {
		return undefined;
	}

	const fraction = scaleValue / wsi4.node.multiplicity(front(article));
	return new Costs(
		fraction * costs.material,
		costs.setup,
		fraction * costs.unit,
	);
}

/**
 * Compute share of the underlying semimanufactured article (if any)
 *
 * Note:  If there is no associated semimanufactured article then the resulting costs are 0.
 */
function computeSemimanufacturedSourceShare(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Costs | undefined {
	if (hasSheetSourceArticle(article)) {
		return computeSheetCostsShare(article, scaleValue, cache);
	} else if (hasTubeSourceArticle(article)) {
		return computeTubeCostsShare(article, scaleValue, cache);
	} else {
		return {
			material: 0,
			setup: 0,
			unit: 0,
		};
	}
}

/**
 * Compute non-recursive scaled costs of production for an article
 *
 * Note:  For joinings the computation is non-recursive.
 *        I.e. the resulting costs *do not* include the costs for the joining's components.
 *
 * Note:  For sheet metal parts the costs *do* include the respective costs share of the underlying sheet.
 *        For tube parts the costs *do* include the respective costs share of the underlying tube.
 *
 * @param article The article
 * @param scaleValue The scale value
 * @param cache The cache
 * @returns Non-recursive scaled selling price
 */
function computeArticleScaleCosts(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Costs|undefined {
	if (isSemimanufacturedArticle(article)) {
		return addCosts(article.map(vertex => computeNodeCosts(vertex, scaleValue, cache)));
	} else {
		return addCosts([
			computeSemimanufacturedSourceShare(article, scaleValue, cache),
			...article.map(vertex => computeNodeCosts(vertex, scaleValue, cache)),
		]);
	}
}

/**
 * Compute scaled value (e.g. `Costs` or selling price) for an article (recursively)
 *
 * @param targetArticle The article
 * @param targetScaleValue The scale value
 * @param computeNonRecursiveScaledValue Function that computes the scaled value for an article non-recursively
 * @param addValues Add `T`s
 * @returns The resulting scaled value
 *
 * If material costs of a component are defined via a dedicated sheet article with two or more targets then the material costs are approximated.
 * If article is a joining article then the scale costs of the joining's components are *included*.
 *
 * Scale prices for articles consisting of a sheet only are 0 (sheet costs are part of the targets of this sheet respectively).
 *
 * NOTE: "Recursive" refers to the incorporation of the entire reaching sub-graph.
 *       The actual computation is non-recursive.
 *       `computeNonRecursiveScaledValue()` is called exactly once for each article of the sub-graph with the respective net-multiplicity.
 */
function computeRecursiveScaledValue<T>(
	targetArticle: readonly Vertex[],
	targetScaleValue: number,
	computeNonRecursiveScaledValue: (article: readonly Vertex[], scaleValue: number) => T,
	addValues: (values: readonly Readonly<T>[]) => T,
): T {
	const reachingArticles: {
		article: readonly Vertex[];
		mult: number;
	}[] = [
		{
			article: targetArticle,
			mult: targetScaleValue,
		},
	];

	const stack: {
		article: readonly Vertex[];
		mult: number;
	}[] = [
		{
			article: targetArticle,
			mult: targetScaleValue,
		},
	];

	while (stack.length !== 0) {
		const target = stack.pop();
		assert(target !== undefined);
		for (const source of wsi4.graph.sources(front(target.article))) {
			const entry = {
				article: wsi4.graph.article(source),
				mult: target.mult * wsi4.graph.sourceMultiplicity(source, front(target.article)),
			};
			const a = reachingArticles.find(r => r.article.some(a => isEqual(a, source)));
			if (a === undefined) {
				reachingArticles.push(entry);
			} else {
				a.mult += entry.mult;
			}
			stack.push(entry);
		}
	}

	assertDebug(() => reachingArticles.every((lhs, lhsIndex) => reachingArticles.every((rhs, rhsIndex) => {
		if (lhsIndex === rhsIndex) {
			return true;
		}
		return !isEqual(lhs.article, rhs.article);
	})), "recursive Articles are not disjoint");

	assert(reachingArticles.length > 0);

	const start = computeNonRecursiveScaledValue(back(reachingArticles).article, back(reachingArticles).mult);
	reachingArticles.pop();
	return reachingArticles.reduce((acc, article) => addValues([
		acc,
		computeNonRecursiveScaledValue(article.article, article.mult),
	]), start);
}

/**
 * Compute recursive manufacturing costs for an article
 *
 * Note:  "recursive" applies to joining articles only.
 *        For non-joining articles the result is the same as calling the regular non-recursive implementation.
 *
 * Note:  All setup costs are incorporated in total and exactly once for each article.
 *        If a component is part of more than one joining, then fixed costs are fully incorporated when computing
 *        the scale price for each joining respecively.  However, each article's fix price component is incorporated
 *        exactly once.
 *
 *        @param targetArticle The article
 *        @param targetScaleValue The scale value
 *        @param cache The cache
 *        @returns Recursive scaled costs of production
 */
export function computeRecursiveArticleScaleCosts(
	targetArticle: readonly Vertex[],
	targetScaleValue: number,
	cache?: CalcCache,
): Costs|undefined {
	const seenFirstVertices: Vertex[] = [];
	return computeRecursiveScaledValue<Costs|undefined>(
		targetArticle,
		targetScaleValue,
		(article, scaleValue) => {
			if (isSemimanufacturedArticle(article)) {
				// Costs of semimanufactured articles are split among all component articles
				return new Costs(0, 0, 0);
			} else {
				const costs = computeArticleScaleCosts(article, scaleValue, cache);
				if (costs === undefined) {
					return undefined;
				} else if (seenFirstVertices.some(vertex => isEqual(vertex, front(article)))) {
					return new Costs(costs.material, 0, costs.unit);
				} else {
					seenFirstVertices.push(front(article));
					return costs;
				}
			}
		},
		addCosts,
	);
}

/**
 * Base values are multiplied with 10^n where 10^n <= `multiplicity` < 10^(n+1).
 *
 * @param multiplicity The multiplicity
 * @param calcSettings The calc settings
 * @returns scale values based on `baseValues` and adjusted to `multiplicity`
 *
 * Resulting scale values are sorted in ascending order.
 *
 * Resulting values < `multiplicity` are filtered out.
 *
 * - 1 is the first scale value (always)
 * - `multiplicity` is the second scale value (if > 1)
 * - adapted base values follow (if > multiplicity)
 */
export function computeScaleValues(multiplicity: number, calcSettings: Readonly<CalcSettings>): number[] {
	switch (calcSettings.scaleValueMode) {
		case "none": return [];
		case "absolute": return [
			multiplicity,
			...calcSettings.baseScaleValues,
		].sort((lhs, rhs) => lhs - rhs);
		case "relative": {
			const baseValues = calcSettings.baseScaleValues;
			const exp = multiplicity.toFixed(0).length - 1;
			assert(exp >= 0, "exp invalid");
			return Array
				.from(new Set([
					1,
					multiplicity,
					...baseValues
						.map(value => value * Math.pow(10, exp))
						.filter(value => value > multiplicity),
				]))
				.sort((lhs, rhs) => lhs - rhs);
		}
	}
}

function scalePriceFromOneUserDefPrice(scalePrice: Readonly<UserDefinedScalePrice>, scaleValue: number): Price {
	if (scalePrice.scaleValue === 0) {
		return {
			fixComponent: scalePrice.price,
			varComponent: 0,
		};
	} else {
		const fraction = scaleValue / scalePrice.scaleValue;
		return {
			fixComponent: 0,
			varComponent: fraction * scalePrice.price,
		};
	}
}

function userDefScalePriceLess(lhs: Readonly<UserDefinedScalePrice>, rhs: Readonly<UserDefinedScalePrice>): number {
	return lhs.scaleValue === rhs.scaleValue ? lhs.price - rhs.price : lhs.scaleValue - rhs.scaleValue;
}

function extractEstimationBase(
	scalePrices: readonly Readonly<UserDefinedScalePrice>[],
	scaleValue: number,
): [
		Readonly<UserDefinedScalePrice>,
		Readonly<UserDefinedScalePrice>,
	] {
	assert(
		scalePrices.length > 1,
		"Pre-condition violated.  Expecting at least two user defined scale prices.",
	);
	assertDebug(
		() => isEqual(
			scalePrices,
			Array.from(scalePrices)
				.sort((lhs, rhs) => userDefScalePriceLess(lhs, rhs)),
		),
		"Pre-condition violated.  Input array is unsorted.",
	);
	assertDebug(
		() => isEqual(
			scalePrices,
			scalePrices.filter((lhs, index, self) => index === self.findIndex(rhs => lhs.scaleValue === rhs.scaleValue)),
		),
		"Pre-condition violated.  Input array is not unique wrt scale values.",
	);

	const index = scalePrices.findIndex(scalePrice => scaleValue <= scalePrice.scaleValue);
	if (index === -1) {
		// Extrapolate beyond last value
		return [
			scalePrices[scalePrices.length - 2]!,
			scalePrices[scalePrices.length - 1]!,
		];
	} else if (index === 0) {
		// Extrapolate below first value
		return [
			scalePrices[0]!,
			scalePrices[1]!,
		];
	} else {
		// Interpolate between values
		return [
			scalePrices[index - 1]!,
			scalePrices[index]!,
		];
	}
}

function scalePriceFromTwoUserDefPrices(scalePrices: readonly Readonly<UserDefinedScalePrice>[], scaleValue: number): Price {
	assert(
		scalePrices.length > 1,
		"Pre-condition violated.  Expecting at least two user defined scale prices",
	);
	assertDebug(
		() => isEqual(
			scalePrices,
			Array.from(scalePrices)
				.sort((lhs, rhs) => userDefScalePriceLess(lhs, rhs)),
		),
		"Pre-condition violated.  Input array is unsorted.",
	);
	assertDebug(
		() => isEqual(
			scalePrices,
			scalePrices.filter((lhs, index, self) => index === self.findIndex(rhs => lhs.scaleValue === rhs.scaleValue)),
		),
		"Pre-condition violated.  Input array is not unique wrt scale values.",
	);

	const [
		p0,
		p1,
	] = extractEstimationBase(scalePrices, scaleValue);

	const dy = p1.price - p0.price;
	const dx = p1.scaleValue - p0.scaleValue;
	assert(dx > 0, "Expecting scale value difference > 0");

	const m = dy / dx;
	const t = p0.price - m * p0.scaleValue;

	// Preventing negative costs as m or t can potentially be negative.
	// This is "compensated" by shifting the other value, respectively.
	return {
		fixComponent: Math.max(0, t + Math.min(0, m * scaleValue)),
		varComponent: Math.max(0, m * scaleValue + Math.min(0, t)),
	};
}

function scalePriceFromUserDefPrices(scalePricesInput: readonly Readonly<UserDefinedScalePrice>[], scaleValue: number): Price {
	assert(scalePricesInput.length > 0, "Expecting at least one user defined scale price");
	const uniqueSortedScalePrices = Array.from(scalePricesInput)
		.sort((lhs, rhs) => userDefScalePriceLess(lhs, rhs))
		.filter((lhs, index, self) => index === self.findIndex(rhs => lhs.scaleValue === rhs.scaleValue));
	if (uniqueSortedScalePrices.length === 1) {
		return scalePriceFromOneUserDefPrice(uniqueSortedScalePrices[0]!, scaleValue);
	} else {
		return scalePriceFromTwoUserDefPrices(uniqueSortedScalePrices, scaleValue);
	}
}

function computeNodeManufacturingPriceInclSurchargesImpl(
	vertex: Vertex,
	scaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	const userData = wsi4.node.userData(vertex);
	if (hasManufacturingCostOverride(vertex, userData)) {
		return scalePriceFromUserDefPrices(nodeUserDatumOrDefault("userDefinedScalePrices", vertex, userData), scaleValue);
	} else {
		const costs = computeNodeCosts(vertex, scaleValue, cache);
		return computeManufacturingCostsInclSurchargesImpl(costs);
	}
}

export function computeNodeManufacturingPriceInclSurcharges(
	vertex: Vertex,
	scaleValue: number,
	cache?: CalcCache,
): number|undefined {
	const price = computeNodeManufacturingPriceInclSurchargesImpl(vertex, scaleValue, cache);
	return price === undefined ? undefined : price.fixComponent + price.varComponent;
}

/**
 * Import should be limited to gui graph representation
 */
export function guiOnlyApplyGlobalSurcharges(
	priceExclGlobSurcharges: number|undefined,
	surchargeTableInput?: readonly Readonly<Surcharge>[],
): number|undefined {
	if (priceExclGlobSurcharges === undefined) {
		return undefined;
	} else {
		const surchargeTable = surchargeTableInput ?? getTable(TableType.surcharge);
		const globalFactor = computeGlobalFactor(surchargeTable);
		return globalFactor * priceExclGlobSurcharges;
	}
}

/**
 * Compute costs share of the underlying sheet node (if any)
 *
 * Note:  If there is no associated sheet node then the resulting costs are 0 respectively.
 */
function computeSheetManufacturingPriceShareInclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	if (isJoiningArticle(article)) {
		return {
			fixComponent: 0,
			varComponent: 0,
		};
	} else {
		const sheetVertex = wsi4.graph.reaching(back(article))
			.find(vertex => wsi4.node.workStepType(vertex) === WorkStepType.sheet);
		if (sheetVertex === undefined) {
			return {
				fixComponent: 0,
				varComponent: 0,
			};
		} else if (hasManufacturingCostOverride(sheetVertex)) {
			// Note: selling price for sheet is considered to be defined for sheet-node-multiplicity 1
			const sheetPrice = computeNodeManufacturingPriceInclSurchargesImpl(sheetVertex, 1, cache);
			const relShare = computeSheetSourceShareForArticle(article);
			if (sheetPrice === undefined || relShare === undefined) {
				return undefined;
			} else {
				return {
					fixComponent: relShare * sheetPrice.fixComponent,
					varComponent: relShare * sheetPrice.varComponent,
				};
			}
		} else {
			const costs = computeSheetCostsShare(article, scaleValue, cache);
			return computeManufacturingCostsInclSurchargesImpl(costs);
		}
	}
}

function computeTubeManufacturingPriceShareInclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	assertDebug(
		() => isComponentArticle(article) && hasTubeSourceArticle(article),
		"Pre-condition violated",
	);

	const tubeVertex = wsi4.graph.reaching(front(article))
		.find(vertex => wsi4.node.workStepType(vertex) === WorkStepType.tube);
	assert(tubeVertex !== undefined, "Expecting valid tube vertex");

	// Note: selling price for tube is considered to be defined for tube-node-multiplicity 1
	const tubePrice = computeNodeManufacturingPriceInclSurchargesImpl(tubeVertex, 1, cache);
	if (tubePrice === undefined) {
		return undefined;
	} else {
		const fraction = scaleValue / wsi4.node.multiplicity(front(article));
		return {
			fixComponent: tubePrice.fixComponent,
			varComponent: fraction * tubePrice.varComponent,
		};
	}
}

/**
 * Compute costs share of the underlying semimanufactured article (if any)
 *
 * Note:  If there is no directly associated semimanufactured node then the resulting costs are 0.
 */
function computeSemimanufacturedManufacturingPriceShareInclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	if (hasSheetSourceArticle(article)) {
		return computeSheetManufacturingPriceShareInclSurcharges(article, scaleValue, cache);
	} else if (hasTubeSourceArticle(article)) {
		return computeTubeManufacturingPriceShareInclSurcharges(article, scaleValue, cache);
	} else {
		return {
			fixComponent: 0,
			varComponent: 0,
		};
	}
}

/**
 * Manufacturing costs excl. overhead surcharge / material surcharge factors
 *
 * For component articles the result includes the costs share of the underlying semimanufactured article (if any)
 *
 * Note: If at least one related vertex has a manually defined manufacturing price incl. surcharges
 *       then this article's manufacturing costs excl. surcharges are undefined.
 */
export function computeArticleManufacturingCostsExclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Costs | undefined {
	if ([
		...wsi4.graph.reaching(front(article)),
		...article,
	].some(vertex => hasManufacturingCostOverride(vertex))) {
		return undefined;
	} else {
		return computeArticleScaleCosts(article, scaleValue, cache);
	}
}

/**
 * Manufacturing price excl. overhead surcharge / material surcharge factors
 *
 * For component articles the result includes the costs share of the underlying semimanufactured article (if any)
 *
 * Note: If at least one related vertex has a manually defined manufacturing price incl. surcharges
 *       then this article's manufacturing costs excl. surcharges are undefined.
 */
export function computeArticleManufacturingPriceExclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): number|undefined {
	if ([
		...wsi4.graph.reaching(front(article)),
		...article,
	].some(vertex => hasManufacturingCostOverride(vertex))) {
		return undefined;
	} else {
		const costs = computeArticleScaleCosts(article, scaleValue, cache);
		return computeManufacturingPriceExclSurcharges(costs);
	}
}

/**
 * Recursive manufacturing price excl. overhead surcharge / material surcharge factors (if defined)
 *
 * Note: If at least one related vertex has a manually defined manufacturing price incl. surcharges
 *       then this article's manufacturing costs excl. surcharges are undefined.
 */
export function computeRecursiveArticleManufacturingPriceExclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): number|undefined {
	if ([
		...wsi4.graph.reaching(front(article)),
		...article,
	].some(vertex => hasManufacturingCostOverride(vertex))) {
		return undefined;
	} else {
		const costs = computeRecursiveArticleScaleCosts(article, scaleValue, cache);
		return computeManufacturingPriceExclSurcharges(costs);
	}
}

function computeArticleManufacturingPriceInclSurchargesImpl(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	const addPrices = (prices: readonly Readonly<Price|undefined>[]): Price|undefined => prices.reduce((acc, price) => acc === undefined ? undefined : price === undefined ? undefined : ({
		fixComponent: acc.fixComponent + price.fixComponent,
		varComponent: acc.varComponent + price.varComponent,
	}));
	if (isSemimanufacturedArticle(article)) {
		return addPrices(
			article.map(vertex => computeNodeManufacturingPriceInclSurchargesImpl(vertex, scaleValue, cache)),
		);
	} else {
		return addPrices([
			computeSemimanufacturedManufacturingPriceShareInclSurcharges(article, scaleValue, cache),
			...article.map(vertex => computeNodeManufacturingPriceInclSurchargesImpl(vertex, scaleValue, cache)),
		]);
	}
}

export function computeArticleManufacturingPriceInclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): number|undefined {
	const price = computeArticleManufacturingPriceInclSurchargesImpl(article, scaleValue, cache);
	return price === undefined ? undefined : price.varComponent + price.fixComponent;
}

function computeRecursiveArticleManufacturingPriceInclSurchargesImpl(
	targetArticle: readonly Vertex[],
	targetScaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	const addPrices = (prices: readonly Readonly<Price|undefined>[]): Price|undefined => prices.reduce((acc, price) => acc === undefined ? undefined : price === undefined ? undefined : ({
		fixComponent: acc.fixComponent + price.fixComponent,
		varComponent: acc.varComponent + price.varComponent,
	}));
	const seenFirstVertices: Vertex[] = [];
	return computeRecursiveScaledValue<Price|undefined>(
		targetArticle,
		targetScaleValue,
		(article, scaleValue) => {
			if (isSemimanufacturedArticle(article)) {
				// Costs of semimanufactured articles are split among all component articles
				return {
					fixComponent: 0,
					varComponent: 0,
				};
			} else {
				const price = computeArticleManufacturingPriceInclSurchargesImpl(article, scaleValue, cache);
				if (price === undefined) {
					return undefined;
				} else if (seenFirstVertices.some(vertex => isEqual(vertex, front(article)))) {
					return {
						fixComponent: 0,
						varComponent: price.varComponent,
					};
				} else {
					seenFirstVertices.push(front(article));
					return price;
				}
			}
		},
		addPrices,
	);
}

/**
 * Recursive manufacturing price incl. overhead surcharge / material surcharge factors
 *
 * Note:  "recursive" applies to joining articles only.
 *        For non-joining articles the result is the same as calling the regular non-recursive implementation.
 *
 * Note:  All fix price components are incorporated in total and exactly once for each article.
 *        If a component is part of more than one joining, then fixed costs are fully incorporated when computing
 *        the scale price for each joining respecively.  However, each article's fix price component is incorporated
 *        exactly once.
 *
 *        @param article The article
 *        @param scaleValue The scale value
 *        @param cache The cache
 *        @returns Recursive scaled selling price
 */
export function computeRecursiveArticleManufacturingPriceInclSurcharges(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): number|undefined {
	const price = computeRecursiveArticleManufacturingPriceInclSurchargesImpl(article, scaleValue, cache);
	return price === undefined ? undefined : price.fixComponent + price.varComponent;
}

/**
 * Compute non-recursive scale selling price for an article
 *
 * Note:  For joinings the computation is non-recursive.
 *        I.e. the resulting price *does not* include the costs for the joining's components.
 *
 * Note:  For sheet metal parts the price *does* include the respective price share of the underlying sheet source article.
 *        For tube parts the price *does* include the price of the underlying tube source article.
 *
 *        @param article The article
 *        @param scaleValue The scale value
 *        @param cache The cache
 *        @returns Non-recursive scaled selling price
 */
function articleSellingPriceWithSemimanufacturedShareImpl(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): Price|undefined {
	const mfgPriceInclSurcharges = computeArticleManufacturingPriceInclSurchargesImpl(article, scaleValue, cache);
	if (mfgPriceInclSurcharges === undefined) {
		return undefined;
	} else {
		return applyGlobalSurcharges(mfgPriceInclSurcharges);
	}
}

function computeRoundedSum(price: Price|undefined, scaleValue: number): number|undefined {
	if (price === undefined) {
		return undefined;
	} else {
		// Apply rounding in a way that allows for consistent prices for multiplicity 1 and the actual multiplicity where all prices are rounded to two decimals.
		return Math.ceil(100 * (price.fixComponent + price.varComponent) / scaleValue) * scaleValue / 100;
	}
}

/**
 * Compute non-recursive scale selling price for an article
 *
 * Note:  For joinings the computation is non-recursive.
 *        I.e. the resulting price *does not* include the costs for the joining's components.
 *
 * Note:  For component articles with semimanufactured source articles the resulting price *does*
 *        include the respective source share.
 *
 *        @param article The article
 *        @param scaleValue The scale value
 *        @param cache The cache
 *        @returns Non-recursive scaled selling price with semimanufactured price share (if any)
 */
export function articleSellingPriceWithSemimanufacturedShare(
	article: readonly Vertex[],
	scaleValue: number,
	cache?: CalcCache,
): number|undefined {
	return computeRoundedSum(
		articleSellingPriceWithSemimanufacturedShareImpl(article, scaleValue, cache),
		scaleValue,
	);
}

/**
 * Compute recursive scale selling price for an article
 *
 * Note:  "recursive" applies to joining articles only.
 *        For non-joining articles the result is the same as calling the regular non-recursive implementation.
 *
 * Note:  All fix price components are incorporated in total and exactly once for each article.
 *        If a component is part of more than one joining, then fixed costs are fully incorporated when computing
 *        the scale price for each joining respecively.  However, each article's fix price component is incorporated
 *        exactly once.
 *
 *        @param targetArticle The article
 *        @param targetScaleValue The scale value
 *        @param cache The cache
 *        @returns Recursive scaled selling price
 */
export function computeRecursiveArticleScaleSellingPrice(
	targetArticle: readonly Vertex[],
	targetScaleValue: number,
	cache?: CalcCache,
): number|undefined {
	const addPrices = (prices: readonly Readonly<number|undefined>[]): number|undefined => prices.reduce((acc, price) => acc === undefined ? undefined : price === undefined ? undefined : acc + price);
	return computeRecursiveScaledValue<number|undefined>(
		targetArticle,
		targetScaleValue,
		(article, scaleValue) => {
			if (isSemimanufacturedArticle(article)) {
				// Costs of semimanufactured articles are split among all component articles
				return .0;
			} else {
				return articleSellingPriceWithSemimanufacturedShare(article, scaleValue, cache);
			}
		},
		addPrices,
	);
}

/**
 * Compute selling price for the entire project
 *
 * Note:  Price *not* computed from excact costs for each node.
 * Instead it is based on approximate article prices.
 *
 * This ensures that the selling prices for each article and the overal selling price are consistent.
 */
export function computeProjectSellingPrice(calcCache?: CalcCache): number|undefined {
	return wsi4.graph.articles()
		.filter(article => !isSemimanufacturedArticle(article))
		.reduce((acc: number|undefined, article) => {
			if (acc === undefined) {
				return undefined;
			}
			const articleSellingPrice = articleSellingPriceWithSemimanufacturedShare(
				article,
				getFixedMultiplicity(front(article)),
				calcCache,
			);
			if (articleSellingPrice === undefined) {
				return undefined;
			}
			return acc + articleSellingPrice;
		}, 0);
}
