import {
	TableType,
} from "qrc:/js/lib/generated/enum";
import {
} from "./calc_costs";
import {
	computeLaserSheetCuttingUnitTimePerPiece,
	LaserSheetCuttingCalcParams,
	laserSheetCuttingRatePerSecond,
} from "./calc_laser_sheet_cutting";
import {
	tolerances,
} from "./constants";
import {
	mappedSheetCuttingMaterialId,
} from "./node_utils";
import {
	getSettingOrDefault,
} from "./settings_table";
import {
	getSharedDataEntry,
} from "./shared_data";
import {
	computeValidLaserCuttingGasIds,
	getMutableTable,
	getSheetsInStock,
	getTable,
	isTubeInStock,
	lookUpProcessSetupTimeFallBack,
	TableTypeMap,
} from "./table_utils";
import {
	ArticleUserData,
	createNodeUserDataStringIndex,
	isCompatibleToNodeUserDataEntryImpl,
	isNodeUserDataEntryType,
	NodeUserDataContext,
	NodeUserDataEntries,
	nodeUserDataEntries,
} from "./userdata_config";
import {
	nodeUserDataContextForVertex,
} from "./userdata_utils";
import {
	assert,
	bbDimensionX,
	bbDimensionY,
	bbDimensionZ,
	lexicographicObjectLess,
} from "./utils";

// The primary motivation for using callbacks instead of pre-computed
// values is that pre-computation breaks certain tests that operate on
// fixtures that in turn break API function post-conditions that have
// been introduced in the meantime.
//
// Example:
// export_wsi4_file.ts processes a .wsi4 file that holds
// non-normalized IOPs in some of its `TwoDimRepresentation`s.
// This triggers an assertion when pre-fetching this twoDimRep.
// Since the respective UserData entry is part of the loaded
// graph there is no need to fetch this twoDimRep.
//
// This can be mitigated by fetching the TwoDimRepresentation on demand.

export interface InitialValueNodeUserDataContextSheetBending {
	type: "sheetBending";
	sheetThickness: () => number;
}

export interface InitialValueNodeUserDataContextSheetCutting {
	type: "sheetCutting";
	processId: () => string;
	multiplicity: () => number;
	sheetThickness: () => number;
	twoDimRep: () => TwoDimRepresentation;
	// Unset if vertex is not source of a sheet bending vertex
	sheetMaterialId: () => string | undefined;
}

export interface InitialValueNodeUserDataContextTubeCutting {
	type: "tubeCutting";
	profileExtrusionLength: () => number;
	tubeProfile: () => TubeProfile | undefined;
}

export interface InitialValueNodeUserDataContextPackaging {
	type: "packaging";
	mass: () => number | undefined;
	assembly: () => Assembly | undefined;
}

export interface InitialValueNodeUserDataContextDefault {
	type: "default";
}

export type InitialValueNodeUserDataContext = InitialValueNodeUserDataContextDefault
| InitialValueNodeUserDataContextSheetBending
| InitialValueNodeUserDataContextSheetCutting
| InitialValueNodeUserDataContextTubeCutting
| InitialValueNodeUserDataContextPackaging;

type Context = Readonly<InitialValueNodeUserDataContext>;

type InitialValueFunctionMap<Entries> = {
	readonly[index in keyof Entries]: (context: Readonly<Context>) => Entries[index];
};

function getDefaultTableRow<Key extends keyof TableTypeMap>(type: Key): Readonly<TableTypeMap[Key]> {
	const table = Array.from(getTable(type))
		.sort(lexicographicObjectLess);
	if (table.length === 0) {
		return wsi4.throwError("Expecting non-empty table");
	}
	return table[0]!;
}

/**
 * Select material according to available sheets (if any)
 */
function computeInitialValueSheetMaterial(context: Context): SheetMaterialUniqueMembers {
	assert(context.type === "sheetBending" || context.type === "sheetCutting", "Context invalid");
	const thickness = context.sheetThickness();
	const matchingSheets = getSheetsInStock()
		.filter(sheet => thickness !== undefined && Math.abs(sheet.thickness - thickness) < tolerances.thickness);
	const sheetMaterialTable = getTable(TableType.sheetMaterial);
	const defaultSheetMaterialId = getSharedDataEntry("defaultSheetMaterialId");

	// Prio 0: valid defaultSheetMaterialId present and sheet found that matches both the settings value and the node's sheet thickness
	// Prio 1: matching sheet available
	// Prio 2: valid defaultSheetMaterialId present
	// Prio 3: default value from global material table
	if (defaultSheetMaterialId !== undefined && sheetMaterialTable.some(row => row.identifier === defaultSheetMaterialId) && matchingSheets.some(row => row.sheetMaterialId === defaultSheetMaterialId)) {
		return {
			identifier: defaultSheetMaterialId,
		};
	} else if (matchingSheets.length > 0) {
		const cheapestSheetId = (() => {
			const matchingSheetRelPrices = getTable(TableType.sheetPrice)
				.filter(sheetPrice => matchingSheets.some(sheet => sheet.identifier === sheetPrice.sheetId))
				.map(sheetPrice => {
					const sheet = matchingSheets.find(sheet => sheet.identifier === sheetPrice.sheetId);
					assert(sheet !== undefined, "Expecting valid sheet");
					const relSheetPrice = sheetPrice.pricePerSheet / (sheet.dimX * sheet.dimY);
					return {
						sheetId: sheet.identifier,
						relSheetPrice: relSheetPrice,
					};
				});
			assert(!wsi4.util.isDebug() || matchingSheetRelPrices.length === matchingSheets.length, "Expecting price for each sheet");
			assert(matchingSheetRelPrices.length > 0, "Expecting at least one matching sheet price");
			return matchingSheetRelPrices
				.reduce(
					(acc, sheetIdAndRelPrice) => {
						if (acc.relSheetPrice < sheetIdAndRelPrice.relSheetPrice) {
							return acc;
						} else {
							return sheetIdAndRelPrice;
						}
					},
					matchingSheetRelPrices[0]!)
				.sheetId;
		})();
		const sheetMaterial = (() => {
			const cheapestSheet = matchingSheets.find(sheet => sheet.identifier === cheapestSheetId);
			return sheetMaterialTable.find(row => row.identifier === cheapestSheet?.sheetMaterialId);
		})();
		return sheetMaterial !== undefined ? sheetMaterial : wsi4.throwError("Tables inconsistent");
	} else if (defaultSheetMaterialId !== undefined && sheetMaterialTable.some(row => row.identifier === defaultSheetMaterialId)) {
		return {
			identifier: defaultSheetMaterialId,
		};
	} else {
		return getDefaultTableRow(TableType.sheetMaterial);
	}
}

function findDefaultTube(profile: TubeProfile, minLength: number): Tube|undefined {
	const tubeStockTable = getTable(TableType.tubeStock);
	return getMutableTable(TableType.tube)
		.filter(row => row.dimX >= minLength)
		.sort((lhs, rhs) => lexicographicObjectLess(rhs, lhs))
		.find(row => row.tubeProfileId === profile.identifier && isTubeInStock(row.identifier, tubeStockTable));

}

function computeInitialValueTubeValue<T>(context: Context, mapTube: (tube: Tube) => T): T|undefined {
	assert(context.type === "tubeCutting", "Context invalid");
	const profile = context.tubeProfile();
	if (profile === undefined) {
		return undefined;
	}

	const clampingLength = getSettingOrDefault("tubeClampingLength");
	const minLength = clampingLength + context.profileExtrusionLength();
	const defaultTube = findDefaultTube(profile, minLength);
	if (defaultTube === undefined) {
		return undefined;
	}

	return mapTube(defaultTube);
}

function computeInitialValueTubeMaterial(context: Context): TubeMaterialUniqueMembers|undefined {
	const map = (tube: Tube): TubeMaterialUniqueMembers => ({
		identifier: tube.tubeMaterialId,
	});
	return computeInitialValueTubeValue(context, map);
}

function computeInitialValueTubeSpecification(context: Context): TubeSpecificationUniqueMembers|undefined {
	const map = (tube: Tube): TubeSpecificationUniqueMembers => ({
		identifier: tube.tubeSpecificationId,
	});
	return computeInitialValueTubeValue(context, map);
}

function computeSheetCuttingMaterialId(context: Context): string|undefined {
	assert(context.type === "sheetCutting", "Context invalid");
	const sheetMaterialId = context.sheetMaterialId() ?? computeInitialValueSheetMaterial(context).identifier;
	return mappedSheetCuttingMaterialId(sheetMaterialId);
}

function computeInitialValueLaserCuttingGas(context: Context): LaserSheetCuttingGasUniqueMembers {
	assert(context.type === "sheetCutting", "Context invalid");
	const fallBackValue = getDefaultTableRow(TableType.laserSheetCuttingGas);

	const twoDimRep = context.twoDimRep();
	const thickness = context.sheetThickness();
	const setupTime = (() => {
		const processId = context.processId();
		const time = lookUpProcessSetupTimeFallBack(processId);
		return time === undefined ? 0 : time;
	})();

	const sheetCuttingMaterialId = computeSheetCuttingMaterialId(context);
	if (sheetCuttingMaterialId === undefined) {
		wsi4.util.warn("computeInitialValueLaserCuttingGas(): No laser sheet cutting material found");
		return fallBackValue;
	}

	const validGasIds = computeValidLaserCuttingGasIds(thickness, sheetCuttingMaterialId);
	if (validGasIds.length === 0) {
		wsi4.util.warn("computeInitialValueLaserCuttingGas(): No valid laser sheet cutting gas found");
		return fallBackValue;
	}

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

	const multiplicity = context.multiplicity();
	const initialId = validGasIds.map(gasId => {
		const price = (() => {
			const calcParams: LaserSheetCuttingCalcParams = {
				twoDimRep: twoDimRep,
				thickness: thickness,
				sheetCuttingMaterialId: sheetCuttingMaterialId,
				laserSheetCuttingGasId: gasId,
				engravings: engravings,
			};
			const ratePerSecond = laserSheetCuttingRatePerSecond(calcParams);
			if (ratePerSecond === undefined) {
				return undefined;
			} else {
				const unitTimePerPiece = computeLaserSheetCuttingUnitTimePerPiece(calcParams);
				const time = setupTime + multiplicity * unitTimePerPiece;
				return time * ratePerSecond;
			}
		})();
		return {
			gasId: gasId,
			price: price === undefined ? Number.MAX_VALUE : price,
		};
	})
		.sort((lhs, rhs) => lhs.price - rhs.price)[0]!
		.gasId;

	const laserSheetCuttingGasTable = getTable(TableType.laserSheetCuttingGas);
	const validCuttingGasRow = laserSheetCuttingGasTable.find(row => row.identifier === initialId);
	if (validCuttingGasRow === undefined) {
		wsi4.util.warn(`computeInitialValueLaserCuttingGas(): No laser sheet cutting gas available for laser sheet cutting gas id "${initialId}"`);
		return fallBackValue;
	} else {
		return validCuttingGasRow;
	}
}

function computeInitialValuePackaging(context: Context): Packaging {
	assert(context.type === "packaging", "Context invalid");
	const fallBackValue = getDefaultTableRow(TableType.packaging);

	const assembly = context.assembly();
	if (assembly === undefined) {
		return fallBackValue;
	}

	const boundingBox = wsi4.geo.util.boundingBox3d(assembly);
	const dimX = bbDimensionX(boundingBox);
	const dimY = bbDimensionY(boundingBox);
	const dimZ = bbDimensionZ(boundingBox);

	const mass = context.mass();
	if (mass === undefined) {
		return fallBackValue;
	}

	const table = Array.from(getTable(TableType.packaging))
		.sort((lhs, rhs) => (lhs.price < rhs.price ? -1 : lhs.price > rhs.price ? 1 : 0));
	const packaging = table.find(row => dimX < row.dimX && dimY < row.dimY && dimZ < row.dimZ && mass < row.maxWeight);
	return packaging === undefined ? fallBackValue : packaging;
}

export function computeInitialArticleUserData(): ArticleUserData {
	return {
		// Note:  The initial article name is computed separately based on a complete graph
		name: "",
		comment: "",
		externalPartNumber: "",
		externalDrawingNumber: "",
		externalRevisionNumber: "",
	};
}

const initialValueNodeFunctionMap: InitialValueFunctionMap<NodeUserDataEntries> = {
	sheetMaterial: computeInitialValueSheetMaterial,
	comment: () => "",
	materialCostsPerPiece: () => undefined,
	setupTime: () => undefined,
	unitTimePerPiece: () => undefined,
	cuttingGas: computeInitialValueLaserCuttingGas,
	packaging: computeInitialValuePackaging,
	transportDistance: () => 0.,
	importId: () => undefined,
	attachments: () => [],
	testReportRequired: () => false,
	fixedRotations: () => [],
	deburrDoubleSided: () => false,
	numThreads: () => 0,
	numCountersinks: () => 0,
	sheetTappingData: () => [],
	articleUserData: () => computeInitialArticleUserData(),
	userDefinedScalePrices: () => [],
	tubeMaterial: computeInitialValueTubeMaterial,
	tubeSpecification: computeInitialValueTubeSpecification,
	sheetFilterSheetIds: () => [],
	bendLineEngravingMode: () => getSharedDataEntry("defaultBendLineEngravingMode"),
};

/**
 * @param key Key of the UserData entry
 * @param context Context of the node the value should be computed for
 * @returns Initial value depending on [[key]] and [context]]
 */
function computeNodeUserDataEntryInitialValue<Key extends keyof NodeUserDataEntries>(key: Key, context: Context): NodeUserDataEntries[Key] {
	const value = initialValueNodeFunctionMap[key](context);
	if (!isNodeUserDataEntryType(key, value)) {
		return wsi4.throwError("Type invalid");
	}
	return value;
}

/**
 * Create updated UserData object with default values added if they where missing
 *
 * Note: This function does *not* change the underlying graph
 */
export function insertInitialValuesIfMissingImpl(nodeUserDataContext: NodeUserDataContext, initialValueContext: Context, userData: StringIndexedInterface): StringIndexedInterface {
	return nodeUserDataEntries()
		.filter(key => isCompatibleToNodeUserDataEntryImpl(key, nodeUserDataContext))
		.filter(key => userData[createNodeUserDataStringIndex(key, nodeUserDataContext)] === undefined)
		.map(key => ({key: key,
			value: computeNodeUserDataEntryInitialValue(key, initialValueContext)}))
		// 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[createNodeUserDataStringIndex(keyAndValue.key, nodeUserDataContext)] = keyAndValue.value;
			return acc;
		}, userData);
}

function initialValueContextForVertex(): InitialValueNodeUserDataContext {
	// This is considered sufficient for now.
	// If a more advanced context is required
	// at some point then this can be extended.
	return {
		type: "default",
	};
}

/**
 * Create updated UserData object with default values added if they where missing
 *
 * @param vertex Vertex of the associated node
 * @param inputUserData UserData object to update;  using the associated node's userData if unset
 *
 * Note: This function does *not* change the underlying graph
 */
export function insertInitialValuesIfMissing(vertex: Vertex, inputUserData?: StringIndexedInterface): StringIndexedInterface {
	const nodeContext = nodeUserDataContextForVertex(vertex);
	const initialValueContext = initialValueContextForVertex();
	const userData = inputUserData ?? wsi4.node.userData(vertex);
	return insertInitialValuesIfMissingImpl(nodeContext, initialValueContext, userData);
}
