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

import {
	tolerances,
} from "./constants";
import {
	isBaseProcessType,
} from "./process";
import {
	getSettingOrDefault,
} from "./settings_table";
import {
	TappingSceneCandidate,
} from "./sheet_tapping_utils";
import {
	getTable,
	tubeProfileForGeometry,
} from "./table_utils";
import {
	collectNodeUserDataEntries,
	isCompatibleToNodeUserDataEntry,
	nodeUserDatumOrDefault,
	userDataAccess,
} from "./userdata_utils";
import {
	assert,
	assertDebug,
	asyncRenderDefaultPng,
	bbDimensionX,
	bbDimensionY,
	cleanFileName,
	computeEntityFaceArea,
	computeSegmentLength,
	isEqual,
} from "./utils";

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

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

export function 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 wsi4.cam.util.cuttingContourCount(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 wsi4.cam.util.cuttingContourLength(twoDimRep);
}

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

export function dieChoiceAlternativesForVertex(vertex: Vertex, sheetMaterialIdOverride?: string): DieChoiceAlternativesEntry[] {
	return wsi4.node.dieChoiceAlternatives(vertex, sheetMaterialIdOverride);
}

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 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 wsi4.cam.bend.dieAffectDistanceMap(dieChoiceArray, bendLineDataArray, thickness);
}

export function cadFeatureTwoDimCentroid(vertex: Vertex, cadFeature: Readonly<CadFeature>): Point2 {
	const projection = wsi4.node.twoDimProjection(vertex, cadFeature);
	const iop = wsi4.geo.util.createIop(projection);
	assert(iop !== undefined, "Expecting valid IOP for the projection of a " + cadFeature.type);
	assertDebug(() => wsi4.geo.util.innerPolygons(iop).length === 0, "Expecting no inner polygons");
	return wsi4.geo.util.centroid(wsi4.geo.util.outerPolygon(iop));
}

/**
 * Note: The associated node is not neccessarily of type `sheetTapping`.
 * E.g. candidates need to be computed in advance for wsi4webui.
 */
export function computeTappingCandidates(vertex: Vertex): TappingSceneCandidate[] {
	const part = wsi4.node.part(vertex);

	// For now legacy graphs are supported where an 2D input sheet parts have no underlying part.
	// In these cases sheet tapping is not supported.
	if (part === undefined) {
		return [];
	}

	const thickness = wsi4.node.sheetThickness(vertex);
	assert(thickness !== undefined, "Pre-condition violated");

	const screwThreads = getTable(TableType.screwThread);
	return wsi4.cad.features(part)
		.filter(feature => feature.type === "throughHole")
		.map((feature): TappingSceneCandidate | undefined => {
			const centroid = cadFeatureTwoDimCentroid(vertex, feature);
			// This is currently limited to ThroughHoles.
			// However, note that this is not enforced at type-level as of now.
			const diameter = wsi4.cad.coreHoleDiameter(feature.content, part);
			const matchingScrewThreads = screwThreads
				.filter(screwThread => (thickness + tolerances.thickness) > screwThread.minDepth)
				.filter(screwThread => Math.abs(screwThread.coreHoleDiameter - diameter) < screwThread.symmetricTolerance);
			return {
				cadFeature: feature,
				center2: centroid,
				matchingScrewThreads: matchingScrewThreads,
			};
		})
		.filter((arg): arg is TappingSceneCandidate => arg !== undefined)
		.filter(candidate => candidate.matchingScrewThreads.length > 0);
}

export function computeMaxBendLineNetLength(vertex: Vertex): number {
	const segmentsMap = wsi4.node.bendLineSegmentsMap(vertex);
	if (segmentsMap.length === 0) {
		return wsi4.throwError("Expecting valid bend line data");
	}
	return segmentsMap.map(entry => entry.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");
	assertDebug(
		() => wsi4.node.workStepType(vertex) === WorkStepType.transform,
		"Expecting WorkStepType transform; actual: "
		+ wsi4.node.workStepType(vertex)
		+ "; process type: " + wsi4.node.processType(vertex),
	);
	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;
}

/**
 * Spawn an async function for the assemblies of the supplied vertices, returning an array of futures.
 * If a vertex doesn't have an assembly, its inputAssembly is used instead.
 * If it has neither, the vertex is ignored.
 */
function createVertexFutures<Data>(vertices: readonly Vertex[], f: (assembly: Assembly) => Data): VertexAnd<Data>[] {
	// 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: f(assembly),
		}));
}

/**
 * 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[], resolution: Resolution): VertexAnd<ArrayBufferFuture>[] {
	return createVertexFutures(vertices, ass => asyncRenderDefaultPng(ass, resolution));
}

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) && nodeUserDatumOrDefault("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);
}

/**
 * Collect all Breps contained in any of the given vertices' Assembly:s, recursively.
 */
export function collectBrepsFromVertices(vertices: readonly Vertex[]): Brep[] {
	return vertices.reduce(
		(acc: Assembly[], vertex) => {
			const assembly = wsi4.node.assembly(vertex) ?? wsi4.node.inputAssembly(vertex);
			if (assembly !== undefined) {
				acc.push(assembly);
				for (const sa of wsi4.geo.assembly.recursiveSubAssemblies(assembly)) {
					acc.push(sa);
				}
			}
			return acc;
		},
		[])
		.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index)
		.map(a => wsi4.geo.assembly.brep(a))
		.filter((b): b is Brep => b !== undefined)
		.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index);
}

/**
 * Collect all Breps associated to a given assembly
 */
export function collectBrepsFromAssembly(assembly: Assembly): Brep[] {
	const breps = [ wsi4.geo.assembly.brep(assembly) ];
	wsi4.geo.assembly.recursiveSubAssemblies(assembly).forEach(sa => {
		breps.push(wsi4.geo.assembly.brep(sa));
	});
	return breps
		.filter((b): b is Brep => b !== undefined)
		.filter((lhs, index, self) => self.findIndex(rhs => isEqual(lhs, rhs)) === index);
}

/**
 * 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("tubeMaterialId", vertex, userDataAccess.article | userDataAccess.reachable);
	assert(entries.length <= 1, "Expecting at most one matching entry");
	return entries.shift();
}

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("tubeSpecificationId", vertex, userDataAccess.article | userDataAccess.reachable);
	assert(entries.length <= 1, "Expecting at most one matching entry");
	return entries.shift();
}

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

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

	const tubeVertices = wsi4.graph.sources(tubeCuttingVertex).filter(v => wsi4.node.workStepType(v) === "tube");
	if (tubeVertices.length !== 1) {
		return undefined;
	}

	return wsi4.node.findAssociatedTube(tubeVertices[0]!);
}

/**
 * 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 = nodeUserDatumOrDefault("bendLineEngravingMode", vertex);
	if (engravingMode === "none") {
		return result;
	}

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

	const segmentsMap = wsi4.node.bendLineSegmentsMap(vertex);

	const addBendLineSegmentsIf = (pred: (bld: BendLineData) => boolean) => {
		bendLineData.forEach(bld => {
			if (pred(bld)) {
				const segments = segmentsMap.find(entry => entry.bendDescriptor === bld.bendDescriptor)?.segments;
				assert(segments !== undefined, "Expecting matching segments map entry for BendDescriptor " + bld.bendDescriptor.toString());
				result.push(...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;
}
