// Note: All functions in this module must conform to DocumentGraphHandler's script engine API.
import {
	DeducedDataContext,
	deducedDataEntries,
	DeducedDataEntries,
	isDeducedDataEntryType,
} from "qrc:/js/lib/deduceddata_config";
import {
	WorkStepType,
} from "qrc:/js/lib/generated/enum";
import {
	nodeLaserSheetCuttingGasId,
} from "./node_utils";
import {
	getSettingOrDefault,
} from "./settings_table";
import {
	extractUserSelection,
	sheetFilterSheetIds,
} from "./sheet_util";
import {
	computeAllowedSheets,
} from "./table_utils";
import {
	collectNodeUserDataEntries,
	getNodeUserDataEntry,
	getNodeUserDataEntryOrThrow,
	isCompatibleToNodeUserDataEntry,
	userDataAccess,
} from "./userdata_utils";
import {
	assert,
	assertDebug,
	bbDimensionX,
	bbDimensionY,
	isEqual,
} from "./utils";

/**
 * Typedef for DeducedData
 */
export declare type DeducedData = StringIndexedInterface;

function deducedDataContextForVertex(vertex: Vertex): DeducedDataContext {
	return {
		workStepType: wsi4.node.workStepType(vertex),
	};
}

function computeSheetMaterialOfVertex(vertex: Vertex): SheetMaterialUniqueMembers {
	const materials = collectNodeUserDataEntries("sheetMaterial", vertex, userDataAccess.article | userDataAccess.reachable)
		.filter((lhs, index, self) => self.findIndex(rhs => lhs.identifier === rhs.identifier) === index);
	return materials.length === 1 ? materials[0]! : wsi4.throwError("Expecting exactly one material. Found materials: " + JSON.stringify(materials));
}

function computeSheetMaterial(vertex: Vertex): SheetMaterialUniqueMembers {
	const deducedDataContext = deducedDataContextForVertex(vertex);
	assert(deducedDataContext.workStepType === WorkStepType.sheet, "Wrong workstepType");
	const targets = wsi4.graph.targets(vertex);
	assert(targets.length > 0, "Expecting at least one target");
	return computeSheetMaterialOfVertex(targets[0]!);
}

/**
 * Compute laserSheetCutting gas of sheet node
 *
 * @param sheetVertex  A sheet node
 * @param target Target of [[sheetVertex]]
 */
function sheetNodeCuttingGas(sheetVertex: Vertex, target: Vertex): LaserSheetCuttingGasUniqueMembers|undefined {
	assert(wsi4.node.workStepType(sheetVertex) === "sheet", "sheetNodeCuttingGas(): Vertex has wrong workStepType \"" + wsi4.node.workStepType(sheetVertex) + "\"");
	const processType = wsi4.node.processType(target);
	if (processType !== "laserSheetCutting") {
		return undefined;
	}
	const gas = nodeLaserSheetCuttingGasId(target);
	assert(gas !== undefined, "sheetNodeCuttingGas(): Missing gas");

	// Note:  When this script is evaluated the graph might be in an "inconsistent" intermediate state.
	// Example:  A sheet's target nodes might have different cutting gases.
	//           Splitting of the common sheet source is performed as a result of this script.
	//           => The following assertion does not hold at the time this script is evaluated.
	/*
	assertDebug(
		() => wsi4.graph.targets(vertex)
			.every(v => nodeLaserSheetCuttingGasId(v) === gas),
		"Expecting consistent cutting gases for each target node",
	);
        */

	return {
		identifier: gas,
	};
}

/**
 * Compute laserSheetCutting gas of sheet node
 *
 * @param vertex  A sheet node
 */
function computeLaserSheetCuttingGas(vertex: Vertex): LaserSheetCuttingGasUniqueMembers|undefined {
	assert(wsi4.node.workStepType(vertex) === "sheet", "computeLaserSheetCuttingGas(): Vertex has wrong workStepType \"" + wsi4.node.workStepType(vertex) + "\"");
	for (const r of wsi4.graph.reachable(vertex)) {
		if (isCompatibleToNodeUserDataEntry("cuttingGas", r)) {
			return sheetNodeCuttingGas(vertex, r);
		}
	}
	return undefined;
}

function computeSheetCuttingProcessId(vertex: Vertex): string {
	assert(wsi4.node.workStepType(vertex) === "sheet", "computeLaserSheetCuttingGas(): Vertex has wrong workStepType \"" + wsi4.node.workStepType(vertex) + "\"");
	const f = wsi4.graph.reachable(vertex).find(r => wsi4.node.workStepType(r) === WorkStepType.sheetCutting);
	assert(f !== undefined, "Missing vertex.");
	return wsi4.node.processId(f);
}

/**
 * NOTE: The case were while computing a graph the sheet cutting twoDimRep is deleted momentarily (in updateExistingSource in documentgraphhandler.cpp) is not met here,
 * because this function is only called for migration of deduced data to old wsi4 files or consistency checks.
 * In both cases the graph is finished,  so every sheet cutting node of sheet node must have a twoDimRep
 */
function checkIfTargetsFit(sheetVertex: Vertex): boolean {
	const deducedDataContext = deducedDataContextForVertex(sheetVertex);
	assert(deducedDataContext.workStepType === WorkStepType.sheet, "Wrong workStepType");
	assert(wsi4.graph.targets(sheetVertex).length >= 1);
	const thickness = wsi4.node.sheetThickness(sheetVertex);
	assert(thickness !== undefined, "Missing thickness");
	const material = computeSheetMaterial(sheetVertex);

	const nestingDistance = getSettingOrDefault("sheetNestingDistance");
	const [
		minDimX,
		minDimY,
	] = wsi4.graph.reachable(sheetVertex)
		.reduce((acc: [number, number], vertex) => {
			if (wsi4.node.workStepType(vertex) !== WorkStepType.sheetCutting) {
				return acc;
			}
			const twoDimRep = wsi4.node.twoDimRep(vertex);
			assert(twoDimRep !== undefined, "Expecting valid twoDimRep for each sheet target");
			const bbox = wsi4.cam.util.boundingBox2(twoDimRep);
			const dimX = bbDimensionX(bbox);
			const dimY = bbDimensionY(bbox);
			return [
				Math.max(acc[0], nestingDistance + Math.max(dimX, dimY)),
				Math.max(acc[1], nestingDistance + Math.min(dimX, dimY)),
			];
		}, [
			0,
			0,
		]);

	return extractUserSelection(sheetVertex, computeAllowedSheets(thickness, material.identifier, minDimX, minDimY)).length > 0;
}

const migrationMap: {readonly[index in keyof DeducedDataEntries]: (source: Readonly<Vertex>) => DeducedDataEntries[index]} = {
	laserSheetCuttingGas: computeLaserSheetCuttingGas,
	sheetCuttingProcessId: computeSheetCuttingProcessId,
	sheetFilterSheetIds: source => sheetFilterSheetIds(source),
	sheetMaterial: computeSheetMaterial,
	targetsFitOnSheet: checkIfTargetsFit,
};

/**
 * @param vertex Vertex of the node to compute deduced data
 * @param key Key of the DeducedData entry
 * @returns Value depending on [[key]] and [context]]
 */
function computeMigrationEntryValue<Key extends keyof DeducedDataEntries>(vertex: Vertex, key: Key): DeducedDataEntries[Key] {
	const value = migrationMap[key](vertex);
	if (!isDeducedDataEntryType(key, value)) {
		return wsi4.throwError("Type invalid");
	}
	return value;
}

const deducedCompatibilityFunctionMap: {readonly[index in keyof DeducedDataEntries]: (context: Readonly<DeducedDataContext>) => boolean} = {
	laserSheetCuttingGas: context => context.workStepType === WorkStepType.sheet,
	sheetCuttingProcessId: context => context.workStepType === WorkStepType.sheet,
	sheetFilterSheetIds: context => context.workStepType === WorkStepType.sheet,
	sheetMaterial: context => context.workStepType === WorkStepType.sheet,
	targetsFitOnSheet: context => context.workStepType === WorkStepType.sheet,
};

/**
 * @param key Key of the DeducedData entry
 * @param context Context for the associated node
 * @returns true if DeducedData entry for key is compatible to associated node
 */
function isCompatibleToDeducedDataEntryImpl<Key extends keyof DeducedDataEntries>(key: Key, context: Readonly<DeducedDataContext>): boolean {
	return deducedCompatibilityFunctionMap[key](context);
}

export function migrateDeducedData(vertex:Vertex, deducedData: DeducedData): DeducedData {
	const deducedDataContext = deducedDataContextForVertex(vertex);
	return deducedDataEntries()
		.filter(key => isCompatibleToDeducedDataEntryImpl(key, deducedDataContext))
		.filter(key => deducedData[key] === undefined)
		.map(key => ({
			key: key,
			value: computeMigrationEntryValue(vertex, key),
		}))
		// Note: setting an object property to undefined seems to lead to malformed QVariantMaps (which in turn leads to conversion errors in scirpt-engine's runScriptModule function)
		// Filtering undefined values here mitigates this phenomenon
		.filter(keyAndValue => keyAndValue.value !== undefined)
		.reduce((acc, keyAndValue) => {
			acc[keyAndValue.key] = keyAndValue.value;
			return acc;
		}, deducedData);
}

function checkIfTargetFits(source: Vertex, target:Vertex): boolean {

	const deducedDataContext = deducedDataContextForVertex(source);
	assert(deducedDataContext.workStepType === WorkStepType.sheet, "Wrong workStepType");
	assert(wsi4.graph.targets(source).length >= 1);
	const thickness = wsi4.node.sheetThickness(source);
	assert(thickness !== undefined, "Missing thickness");
	const material = computeSheetMaterial(source);

	const nestingDistance = getSettingOrDefault("sheetNestingDistance");

	const twoDimRep = wsi4.node.twoDimRep(target);
	assert(twoDimRep !== undefined, "Expecting valid twoDimRep for each sheet target");
	const bbox = wsi4.cam.util.boundingBox2(twoDimRep);
	const dimX = bbDimensionX(bbox);
	const dimY = bbDimensionY(bbox);

	const minDimX = nestingDistance + Math.max(dimX, dimY);
	const minDimY = nestingDistance + Math.min(dimX, dimY);

	return extractUserSelection(source, computeAllowedSheets(thickness, material.identifier, minDimX, minDimY)).length > 0;
}

const initialValueNodeFunctionMap: {readonly[index in keyof DeducedDataEntries]: (source: Readonly<Vertex>, target:Readonly<Vertex>) => DeducedDataEntries[index]} = {
	laserSheetCuttingGas: sheetNodeCuttingGas,
	sheetCuttingProcessId: (_source, target) => wsi4.node.processId(target),
	sheetFilterSheetIds: (_source, target) => {
		const f = getNodeUserDataEntryOrThrow("sheetFilterSheetIds", target);
		return f.length === 0 ? undefined : f;
	},
	sheetMaterial: (_source, target) => computeSheetMaterialOfVertex(target),
	targetsFitOnSheet: checkIfTargetFits,
};

/**
 * @param key Key of the DeducedData entry
 * @param source Vertex of the node to compute deduced data
 * @param target Vertex of the node to deduce data from (target of vertex)
 * @returns Initial deduced data value
 */
function computeInitialDeducedDataEntryValue<Key extends keyof DeducedDataEntries>(key: Key, source: Vertex, target: Vertex): DeducedDataEntries[Key] {
	const value = initialValueNodeFunctionMap[key](source, target);
	if (!isDeducedDataEntryType(key, value)) {
		return wsi4.throwError("Type invalid");
	}
	return value;
}

/**
 * Create DeducedData object with default values
 *
 * @param source Vertex of the node we compute deducedData for
 * @param target Vertex of the node the data is deduced from (target of source)
 *
 * Note: This function does *not* change the underlying graph
 */
export function computeInitialDeducedData(source: Vertex, target: Vertex): DeducedData {
	const deducedDataContext = deducedDataContextForVertex(source);
	const compatibleEntries =
	deducedDataEntries()
		.filter(key => isCompatibleToDeducedDataEntryImpl(key, deducedDataContext));
	const deducedData = wsi4.node.deducedData(target);
	assert(compatibleEntries.filter(key => deducedData[key] === undefined).length === compatibleEntries.length);
	return compatibleEntries
		.map(key => ({
			key: key,
			value: computeInitialDeducedDataEntryValue(key, source, target),
		}))
		// Note: setting an object property to undefined seems to lead to malformed QVariantMaps (which in turn leads to conversion errors in scirpt-engine's runScriptModule function)
		// Filtering undefined values here mitigates this phenomenon
		.filter(keyAndValue => keyAndValue.value !== undefined)
		.reduce((acc, keyAndValue) => {
			acc[keyAndValue.key] = keyAndValue.value;
			return acc;
		}, deducedData);
}

/**
 * @param key Key of the DeducedData entry
 * @param vertex Vertex of the node to check
 * @returns true if DeducedData entry for key is compatible to vertex
 *
 * Note: This function should be called in the main script engine only.
 *       For incomplete graphs (aka when required in the internal engine)
 *       consider using the associated impl function.
 */
function isCompatibleToDeducedDataEntry<Key extends keyof DeducedDataEntries>(key: Key, vertex: Vertex): boolean {
	const context = deducedDataContextForVertex(vertex);
	return isCompatibleToDeducedDataEntryImpl(key, context);
}

/**
 * Get DeducedData entry for given key and vertex.
 *
 * Note: If DeducedData entry is incompatible to vertex an error is thrown.
 */
function getDeducedDataEntry<Key extends keyof DeducedDataEntries>(key: Key, vertex: Vertex, deducedData: StringIndexedInterface): DeducedDataEntries[Key]|undefined {
	if (!isCompatibleToDeducedDataEntry(key, vertex)) {
		return wsi4.throwError("Get UserData entry \"" + key + "\" for incompatible vertex with key \"" + wsi4.util.toKey(vertex) + "\"");
	}
	deducedData = deducedData !== undefined ? deducedData : wsi4.node.deducedData(vertex);
	const value = deducedData[key];
	return isDeducedDataEntryType(key, value) ? value : undefined;
}

/**
 * Get DeducedData entry for given key and vertex.
 *
 * @param key DeducedData entry key
 * @param vertex Vertex of the node to access
 * @returns Value for the DeducedData [[key]]
 *
 * Note: If DeducedData entry is incompatible to vertex an error is thrown.
 * Note: If DeducedData entry is not available for vertex an error is thrown.
 */
export function getDeducedDataEntryOrThrow<Key extends keyof DeducedDataEntries>(key: Key, vertex: Vertex, deducedData?: StringIndexedInterface): DeducedDataEntries[Key] {
	const value = getDeducedDataEntry(key, vertex, deducedData ?? wsi4.node.deducedData(vertex));
	return isDeducedDataEntryType(key, value) ? value : wsi4.throwError("No DeducedData entry for key \"" + key + "\" and vertex \"" + wsi4.util.toKey(vertex) + "\"");
}

const consistencyCheckMap : {[index in keyof DeducedDataEntries]: (vertex: Vertex, value: DeducedDataEntries[index]) => boolean} = {
	laserSheetCuttingGas: (vertex: Vertex, value: LaserSheetCuttingGasUniqueMembers|undefined) => {
		for (const r of wsi4.graph.reachable(vertex)) {
			if (isCompatibleToNodeUserDataEntry("cuttingGas", r)) {
				const gas = getNodeUserDataEntry("cuttingGas", r);
				if (value === undefined) {
					return gas === undefined;
				}
				if (gas === undefined) {
					assertDebug(() => value !== undefined);
					return false;
				}
				return value.identifier === gas.identifier;
			}
		}
		return true;
	},
	sheetCuttingProcessId: (vertex: Vertex, value: string) => {
		for (const r of wsi4.graph.reachable(vertex)) {
			if (wsi4.node.workStepType(r) === WorkStepType.sheetCutting) {
				if (value !== wsi4.node.processId(r)) {
					return false;
				}
			}
		}
		return true;
	},
	sheetFilterSheetIds: (vertex: Vertex, value: string[]|undefined) => {
		const ids = sheetFilterSheetIds(vertex);
		if (value === undefined) {
			return ids === undefined;
		}
		if (ids === undefined) {
			assertDebug(() => value !== undefined);
			return false;
		}
		const sort = (vec: string[]) => vec.filter((v, index, self) => self.indexOf(v) === index);
		return isEqual(sort(value), sort(ids));
	},
	sheetMaterial: (vertex: Vertex, value: SheetMaterialUniqueMembers) => {
		for (const r of wsi4.graph.reachable(vertex)) {
			if (isCompatibleToNodeUserDataEntry("sheetMaterial", r)) {
				if (value.identifier !== getNodeUserDataEntryOrThrow("sheetMaterial", r).identifier) {
					return false;
				}
			}
		}
		return true;
	},
	targetsFitOnSheet: (sheetVertex: Vertex, value: boolean) => value === checkIfTargetsFit(sheetVertex),
};

function isConsistent<Key extends keyof DeducedDataEntries>(key: Key, vertex: Vertex, value: DeducedDataEntries[Key]) {
	return consistencyCheckMap[key](vertex, value);
}

export function nodeDeducedDataConsistent(vertex: Vertex): boolean {
	const deducedData = wsi4.node.deducedData(vertex);
	for (const key of deducedDataEntries()) {
		if (!isCompatibleToDeducedDataEntry(key, vertex)) {
			continue;
		}
		const d = getDeducedDataEntry(key, vertex, deducedData);
		if (!isConsistent(key, vertex, d)) {
			return false;
		}
	}
	return true;
}
