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

import {
	tolerances,
} from "./constants";
import {
	add2,
	normalize2,
	scale2,
	squareNorm2,
	sub2,
} from "./geometry_utils";
import {
	isBaseProcessType,
} from "./process";
import {
	getSettingOrDefault,
} from "./settings_table";
import {
	getSharedDataEntry,
} from "./shared_data";
import {
	addSheetTappingNumbersToScene,
	addSheetTappingTextToScene,
	computeTappingCandidates,
} from "./sheet_tapping_utils";
import {
	getMutableTable,
	getTable,
	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,
} 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[];
}

/*
 * @param bendLineData BendLineData for the bend
 * @flangeLength BendLineFlangeLength for the bend
 * @extraFlangeLength "safety distance" that is added to the geometrically computed minimum
 *
 * @return Approx. max. opening width a lower die must have to ensure that none of the bend flanges slides into the tool.
 *
 * As of now the elastic rebound of the part is not considered (a slightly larger bend angle is required to achieve the actual bend angle).
 */
export function maxLowerDieOpeningWidth(
	bendLineData: Readonly<BendLineData>,
	flangeLength: Readonly<BendLineFlangeLength>,
	extraFlangeLength: number,
): number {
	// 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 bendAngle = Math.min(maxSingleBendAngle, Math.abs(bendLineData.bendAngle));
	assert(bendAngle >= 0, "bendAngle must be non-negative");
	const factor = Math.cos(.5 * bendAngle);
	const fl0 = Math.max(0, (flangeLength.flangeLengthLhs - extraFlangeLength));
	const fl1 = Math.max(0, (flangeLength.flangeLengthRhs - extraFlangeLength));
	const a = factor * fl0;
	const b = factor * fl1;
	return 2 * Math.min(a, b);
}

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 = front(bendLineData.segments);
	const lastSegment = back(bendLineData.segments);
	const bendLineLength = Math.sqrt(
		squareNorm2(sub2(
			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 extraFlangeLength = getSettingOrDefault("bendFlangeSafetyDistance");
	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));

	return bendLineDataArray
		.map((bendLineData: BendLineData) => {
			const flangeLength = bendLineFlangeLengths.find(entry => entry.bendDescriptor === bendLineData.bendDescriptor);
			assert(flangeLength !== undefined, "Bend data inconsistent");
			const maxOpeningWidth = maxLowerDieOpeningWidth(bendLineData, flangeLength, extraFlangeLength);
			const query = {
				thickness: thickness,
				bendAngle: Math.abs(bendLineData.bendAngle),
				maxOpeningWidth: maxOpeningWidth,
			};
			const constructedRadius = bendLineData.constructedInnerRadius;
			return {
				bendDescriptor: bendLineData.bendDescriptor,
				bendDieChoices: wsi4.cam.bend.selectDieGroups(bendDeductions, upperDieGroups, lowerDieGroups, dieGroupPriorities, query, constructedRadius)
					.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.toString());
		return [];
	}
	const dieChoiceArray = wsi4.node.dieChoiceMap(vertex);
	if (dieChoiceArray === undefined) {
		wsi4.util.error("computeUpperDieAffectDistances(): die choice missing for vertex " + vertex.toString());
		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 computeLowerDieAffectDistanceMapImpl(
	dieChoiceMap: readonly Readonly<DieChoiceMapEntry>[],
	bendLineDataMap: readonly Readonly<BendLineData>[],
	thickness: number,
): BendDieAffectDistanceEntry[] {
	assertDebug(() => dieChoiceMap.length === bendLineDataMap.length, "Pre-condition violated");
	const lowerDieGroupTable = getTable(TableType.lowerDieGroup);
	return dieChoiceMap.map(entry => {
		const lowerDieGroup = lowerDieGroupTable.find(element => element.identifier === entry.bendDieChoice.lowerDieGroupId);
		if (lowerDieGroup === undefined) {
			if (entry.bendDieChoice.lowerDieGroupId.length !== 0) {
				wsi4.util.error("computeLowerDieAffectDistanceMap(): lower die group not found in table: " + entry.bendDieChoice.lowerDieGroupId);
			}
			return {
				bendDescriptor: entry.bendDescriptor,
				affectDistance: 0,
			};
		} else {
			const bendLineData = bendLineDataMap.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;
			const distance = roundPart + straightPart + entry.bendDieChoice.baseClass.roundDeduction;
			return {
				bendDescriptor: entry.bendDescriptor,
				affectDistance: distance,
			};
		}
	});
}

export function computeLowerDieAffectDistanceMap(vertex: Vertex): BendDieAffectDistanceEntry[] {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.sheetBending, "Pre-condition violated");

	const thickness = wsi4.node.sheetThickness(vertex);
	assert(thickness !== undefined);

	const bendLineDataArray = wsi4.node.computeBendLineData(vertex);
	assert(bendLineDataArray !== undefined);

	const dieChoiceArray = wsi4.node.dieChoiceMap(vertex);
	assert(dieChoiceArray !== undefined);

	return computeLowerDieAffectDistanceMapImpl(dieChoiceArray, bendLineDataArray, thickness);
}

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.toString() + " 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 twoDimRep = wsi4.node.twoDimRep(vertex);
		assert(twoDimRep !== undefined, "Expecting valid TwoDimRepresentation");

		const affectDistanceMap = computeLowerDieAffectDistanceMap(vertex);
		const segments = wsi4.cam.bend.affectedSegments(twoDimRep, affectDistanceMap);
		updatedScene = wsi4.geo.util.addSegmentsToScene(updatedScene, segments, {
			strokeWidth: 2,
			strokeColor: Color.blue,
		});

		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;
}

/**
 * If configured, split bend line segments in case they exceed a certain threshold
 *
 * For an engravingLength of 0 the bend line segments are not split at all.
 * For an engravingLength > 0 the bend line segments are split in case
 * a segment's length exceeds 4 x engravingLength.
 */
function splitBendLineSegmentsIfRequired(inputSegments: Segment[], engravingLength: number): Segment[] {
	const result: Segment[] = [];
	inputSegments.forEach(segment => {
		const from = segment.content.from;
		const to = segment.content.to;
		const u = sub2(to, from);
		const l = Math.sqrt(squareNorm2(u));
		if (engravingLength === 0 || l < 4 * engravingLength) {
			result.push(segment);
		} else {
			const v = scale2(engravingLength, normalize2(u));
			result.push({
				type: "line",
				content: {
					from: from,
					to: add2(from, v),
				},
			});
			result.push({
				type: "line",
				content: {
					from: sub2(to, v),
					to: to,
				},
			});
		}
	});
	return result;
}

function createSheetCuttingSpecificScene(vertex: Vertex, baseScene: Scene): Scene {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.sheetCutting, "Pre-condition violated");
	const engravingMode = getNodeUserDataEntryOrThrow("bendLineEngravingMode", vertex);
	if (engravingMode === "none") {
		return baseScene;
	}

	const bendLineData = wsi4.node.computeBendLineData(vertex);
	assert(bendLineData !== undefined, "Expecting valid bend line data for sheet bending node");

	const maxEngravingLength = getSharedDataEntry("bendLineEngravingLength");
	const addBendLineEngravings = (scene: Scene, strokeStyle: StrokeStyle, pred: (bld: BendLineData) => boolean) => {
		const segments: Segment[] = [];
		bendLineData.filter(bld => pred(bld)).forEach(bld => {
			segments.push(...splitBendLineSegmentsIfRequired(bld.segments, maxEngravingLength));
		});
		return wsi4.geo.util.addSegmentsToScene(scene, segments, {
			strokeColor: Color.engraving,
			strokeWidth: 1,
			strokeStyle: strokeStyle,
		});
	};

	switch (engravingMode) {
		case "upwardOnly": {
			return addBendLineEngravings(baseScene, StrokeStyle.continuous, bld => bld.bendAngle >= 0.);
		}
		case "downwardOnly": {
			return addBendLineEngravings(baseScene, StrokeStyle.dashed, bld => bld.bendAngle < 0.);
		}
		case "all": {
			let scene = baseScene;
			scene = addBendLineEngravings(scene, StrokeStyle.continuous, bld => bld.bendAngle >= 0.);
			scene = addBendLineEngravings(scene, StrokeStyle.dashed, bld => bld.bendAngle < 0.);
			return scene;
		}
	}
}

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 {
	return {
		bendLineShowLabels: config.bendLineShowLabels ?? false,
		bendLineShowAffectedSegments: config.bendLineShowAffectedSegments ?? false,
		sheetTappingLabelMode: config.sheetTappingLabelMode ?? "names",
		tubeCuttingShowTubeContours: config.tubeCuttingShowTubeContours ?? false,
		tubeCuttingShowVirtualCuts: config.tubeCuttingShowVirtualCuts ?? false,
	};
}

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.sheetCutting || processType === ProcessType.laserSheetCutting) {
		return createSheetCuttingSpecificScene(vertex, baseScene);
	} else 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);
}

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;
	}
}

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 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);
}

/**
 * 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;
}

export function engravingSegmentsForVertex(vertex: Vertex): Segment[] {
	assertDebug(() => wsi4.node.workStepType(vertex) === WorkStepType.sheetCutting, "Pre-condition violated");

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

	const result = wsi4.geo.util.sceneSegments(
		wsi4.cam.util.extractEngravings(twoDimRep),
	);

	const engravingMode = getNodeUserDataEntryOrThrow("bendLineEngravingMode", vertex);
	if (engravingMode === "none") {
		return result;
	}

	const bendLineData = wsi4.node.computeBendLineData(vertex);
	assert(bendLineData !== undefined, "Expecting valid bend line data");

	const addBendLineSegmentsIf = (pred: (bld: BendLineData)=> boolean) => {
		bendLineData.forEach(bld => {
			if (pred(bld)) {
				result.push(...bld.segments);
			}
		});
	};

	switch (engravingMode) {
		case "upwardOnly": addBendLineSegmentsIf(bld => bld.bendAngle >= 0); break;
		case "downwardOnly": addBendLineSegmentsIf(bld => bld.bendAngle < 0); break;
		case "all": addBendLineSegmentsIf(() => true); break;
	}

	return result;
}

