// Note: All functions in this module must conform to DocumentGraphHandler's script engine API.
import {
	isStringIndexedInterface,
} from "qrc:/js/lib/generated/typeguard";
import {
	GraphNodeProcessingState,
} from "qrc:/js/lib/generated/enum";

import {
	isCompatibleToNodeUserDataEntryImpl,
	isNodeUserDataEntryType,
	nodeUserDataEntries,
	NodeUserDataEntries,
	nodeUserDataProcessingStateMap,
	NodeUserDataContext,
	nodeUserDatumImpl,
} from "./userdata_config";
import {
	assertDebug,
	getKeysOfObject,
	isEqual,
	isString,
} from "./utils";
import {
	min as computeMinProcessingState,
} from "./graphnodeprocessingstate_utils";

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

export function nodeUserDataContextForVertex(vertex: Vertex): NodeUserDataContext {
	return {
		workStepType: wsi4.node.workStepType(vertex),
		processType: wsi4.node.processType(vertex),
		isInitialNode: wsi4.node.isInitial(vertex),
	};
}

export function nodeUserDatum<Key extends keyof NodeUserDataEntries>(key: Key, vertex: Vertex, userData = wsi4.node.userData(vertex)): NodeUserDataEntries[Key] | undefined {
	assertDebug(() => isCompatibleToNodeUserDataEntry(key, vertex), "UserData entry '" + key + "' queried for incompatible node");
	return nodeUserDatumImpl(key, userData);
}

type NodeUserDataEntriesWithDefaults = Pick<NodeUserDataEntries,
"attachments"
| "bendLineEngravingMode"
| "comment"
| "deburrDoubleSided"
| "fixedRotations"
| "numCountersinks"
| "numThreads"
| "sheetFilterSheetIds"
| "sheetTappingData"
| "testReportRequired"
| "userDefinedScalePrices"
>

/**
 * The use of arrow functions (as opposed to hard-coded default values) prevents accidential
 * mutation of e.g. shallow-copied Arrays in the original global default value object.
 */
const defaultNodeUserDatumMap: {[Key in keyof NodeUserDataEntriesWithDefaults]: () => NodeUserDataEntries[Key]} = Object.freeze({
	attachments: () => [],
	bendLineEngravingMode: () => "none",
	comment: () => "",
	deburrDoubleSided: () => false,
	fixedRotations: () => [],
	numCountersinks: () => 0,
	numThreads: () => 0,
	sheetFilterSheetIds: () => [],
	sheetTappingData: () => [],
	testReportRequired: () => false,
	userDefinedScalePrices: () => [],
});

/**
 * Convenience getter for node UserData entries with sensible (typically "empty") defaults.
 */
export function nodeUserDatumOrDefault<Key extends keyof NodeUserDataEntriesWithDefaults>(key: Key, vertex: Vertex, userDataInput?: StringIndexedInterface): NodeUserDataEntriesWithDefaults[Key] {
	assertDebug(() => isCompatibleToNodeUserDataEntry(key, vertex), "UserData entry '" + key + "' queried for incompatible node");
	const userData = userDataInput ?? wsi4.node.userData(vertex);
	return nodeUserDatumImpl(key, userData) ?? defaultNodeUserDatumMap[key]();
}

/**
 * Create new UserData for given key and vertex
 *
 * @param key UserData entry key
 * @param vertex Vertex of the node to modify
 * @param value The value to set
 * @param userDataInput If set, this object will be updated and returned.  If unset, the associated node's UserData is queried, updated and returned.
 * @returns Updated UserData object
 *
 * Note: If UserData entry is incompatible to vertex an error is thrown.
 * Note: This function does *not* change the underlying graph
 */
export function createUpdatedNodeUserData<Key extends keyof NodeUserDataEntries>(key: Key, vertex: Vertex, value: NodeUserDataEntries[Key] | undefined, userDataInput?: StringIndexedInterface): StringIndexedInterface {
	let userData = userDataInput ?? wsi4.node.userData(vertex);
	assertDebug(() => isCompatibleToNodeUserDataEntry(key, vertex), "Set UserData entry \"" + key + "\" for incompatible vertex with key \"" + wsi4.util.toKey(vertex) + "\"");
	if (value !== undefined) {
		userData[key] = value;
	} else {
		const keys = Object.keys(userData);
		if (keys.some(k => k === key)) {
			const obj: StringIndexedInterface = {};
			keys.filter(k => k !== key).forEach(k => {
				obj[k] = userData[k];
			});
			userData = obj;
		}
	}
	return userData;
}

/**
 * Flags for accessing user data of specific nodes (to be used as a flag set)
 *
 * @constant {object}
 * @property {number} self - access user data of the node itself
 * @property {number} reaching - access user data of the nodes reaching the vertex
 * @property {number} reachable - access user data of the nodes reachable from the vertex
 * @property {number} article - access user data of the nodes of the article of the node
 */
export const userDataAccess = {
	self: 1,
	reaching: 2,
	reachable: 4,
	article: 8,
};

/**
 * @constant Access user data for all possible UserDataAccessFlags
 */
export const accessAllUserData = userDataAccess.self | userDataAccess.reaching | userDataAccess.reachable;

/**
 * @constant Access user data of the vertex and an reaching vertices
 */
export const accessSelfAndReachable = userDataAccess.self | userDataAccess.reachable;

const isCloseNodeUserDataFunctionMap: {[index in keyof NodeUserDataEntries]: (lhs: NodeUserDataEntries[index], rhs: NodeUserDataEntries[index]) => boolean} = {
	sheetMaterialId: isEqual,
	comment: isEqual,
	userDefinedMaterialCostsPerPiece: isEqual,
	userDefinedSetupTime: isEqual,
	userDefinedUnitTimePerPiece: isEqual,
	laserSheetCuttingGasId: isEqual,
	attachments: isEqual,
	testReportRequired: isEqual,
	fixedRotations: isEqual,
	deburrDoubleSided: isEqual,
	numThreads: isEqual,
	numCountersinks: isEqual,
	sheetTappingData: (lhsEntries, rhsEntries) => lhsEntries.length === rhsEntries.length && lhsEntries.every(lhs => rhsEntries.some(rhs => isEqual(lhs, rhs))),
	userDefinedScalePrices: isEqual,
	tubeMaterialId: isEqual,
	tubeSpecificationId: isEqual,
	sheetFilterSheetIds: (lhs, rhs) => {
		const sort = (vec: string[]) => vec.filter((v, index, self) => self.indexOf(v) === index);
		return isEqual(sort(lhs), sort(rhs));
	},
	bendLineEngravingMode: isEqual,
};

/**
 * Check if two user data entries are close
 *
 * For user data entries that hold table values this check is limited to the unique members respectively.
 * All other entries are checked for equality.
 */
function isCloseNodeUserDataEntry<Key extends keyof NodeUserDataEntries>(key: Key, lhs: NodeUserDataEntries[Key], rhs: NodeUserDataEntries[Key]): boolean {
	// XXX Caution!
	// As of both lhs and rhs will *not* be type-checked by the comiler as one would expect *on the call site*.
	// Instead, both lhs and rhs can be *any* node user data property type.
	assertDebug(() => isNodeUserDataEntryType(key, lhs), `Pre-condition violated: lhs is incompatible to key ${key}: ${JSON.stringify(lhs)}`);
	assertDebug(() => isNodeUserDataEntryType(key, lhs), `Pre-condition violated: rhs is incompatible to key ${key}: ${JSON.stringify(rhs)}`);
	return isCloseNodeUserDataFunctionMap[key](lhs, rhs);
}

/**
 * Collect UserData for all connected GraphNode~s (depending on access flags)
 *
 * @param key - Node UserData entry key for entries to collect
 * @param vertex - Vertex number
 * @param accessFlag - for which connected vertices to collect user data from
 */
export function collectNodeUserDataEntries<Key extends keyof NodeUserDataEntries>(key: Key, vertex: Vertex, accessFlag: number): NodeUserDataEntries[Key][] {
	const contained = (flag: number, flagSet: number) => (flag & flagSet) === flag;
	const vertices: Vertex[] = [];
	if (contained(userDataAccess.self, accessFlag)) {
		vertices.push(vertex);
	}
	if (contained(userDataAccess.reaching, accessFlag)) {
		vertices.push(...wsi4.graph.reaching(vertex));
	}
	if (contained(userDataAccess.reachable, accessFlag)) {
		vertices.push(...wsi4.graph.reachable(vertex));
	}
	if (contained(userDataAccess.article, accessFlag)) {
		vertices.push(...wsi4.graph.article(vertex));
	}

	const result: NodeUserDataEntries[Key][] = [];
	vertices.filter(v => isCompatibleToNodeUserDataEntry(key, v))
		.forEach(v => {
			const value = nodeUserDatum(key, v);
			if (value !== undefined && result.every(otherValue => !isCloseNodeUserDataEntry(key, value, otherValue))) {
				result.push(value);
			}
		});
	return result;
}

/**
 * Type guard for UserData
 *
 * We cannot write "a is UserData" apparently...?!
 */
export function isUserData(a: unknown): a is UserData {
	return isStringIndexedInterface(a);
}

/**
 * Merge two UserDatas
 * NOTE: Entries already present are not overwritten
 *
 * @constant toExtend UserData to extend
 * @constant toMerge UserData to merge into toExtend (old entries are not overwritten)
 */
export function mergeUserData(toExtend: UserData, toMerge: UserData): UserData {
	// Note that we have to take toMerge first so its entries are overwritten by toExtend.
	return {...toMerge,
		...toExtend};
}

/**
 * Remove values in "oldUserData", that are not present in "newUserData" anymore
 */
export function removeInvalidUserData(oldUserData: UserData, newUserData: UserData): UserData {
	const result: UserData = {};
	for (const key of Object.keys(oldUserData)) {
		if (newUserData[key] !== undefined) {
			result[key] = oldUserData[key];
		}
	}
	return result;
}

/**
 * Merge [[ArticleAttributes]] of two graphnodes
 */
export function mergeArticleAttributes(lhs: ArticleAttributes, rhs: ArticleAttributes): ArticleAttributes {
	// FIXME attachments are overwritten (see #1428)
	const userData = mergeUserData(lhs.userData, rhs.userData);

	if (typeof lhs.userData["name"] !== "string") {
		userData["name"] = rhs.userData["name"];
	}
	return {
		userData: userData,
	};
}

export function hasManufacturingCostOverride(vertex: Vertex, userData?: Readonly<UserData>): boolean {
	return nodeUserDatumOrDefault("userDefinedScalePrices", vertex, userData).length > 0;
}

/**
 * @param key Key of the UserData entry
 * @param vertex Vertex of the node to check
 * @returns true if UserData 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.
 */
export function isCompatibleToNodeUserDataEntry<Key extends keyof NodeUserDataEntries>(key: Key, vertex: Vertex): boolean {
	const context = nodeUserDataContextForVertex(vertex);
	return isCompatibleToNodeUserDataEntryImpl(key, context);
}

function toVertexOrThrow(vertexKey: unknown): Vertex {
	const vertex = wsi4.graph.vertices()
		.find(v => wsi4.util.toKey(v) === vertexKey);
	return vertex === undefined ? wsi4.throwError("Vertex key invalid: " + JSON.stringify(vertexKey)) : vertex;
}

/**
 * Compute new processing state resulting from the changes between two UserData objects
 *
 * @param vertexKey Key of the vertex of the associated node
 * @param oldUserData Unmodified UserData object
 * @param newUserData Modified UserData object
 * @returns Resulting processing state
 */
export function computeNodeProcessingState(vertexKey: unknown, oldUserData: unknown, newUserData: unknown): GraphNodeProcessingState {
	const vertex = toVertexOrThrow(vertexKey);
	if (!isStringIndexedInterface(oldUserData)) {
		return wsi4.throwError("Input invalid");
	}
	if (!isStringIndexedInterface(newUserData)) {
		return wsi4.throwError("Input invalid");
	}
	const knownKeys = nodeUserDataEntries();
	const result = getKeysOfObject(newUserData)
		.filter((userDataIndex): userDataIndex is string => isString(userDataIndex))
		.filter((key): key is keyof NodeUserDataEntries => knownKeys.some(k => k === key))
		.filter(key => isCompatibleToNodeUserDataEntry(key, vertex))
		.filter(key => {
			const lhs = oldUserData[key];
			const rhs = newUserData[key];
			return !isNodeUserDataEntryType(key, lhs) || !isNodeUserDataEntryType(key, rhs) || !isCloseNodeUserDataEntry(key, lhs, rhs);
		})
		.reduce((acc: GraphNodeProcessingState, key) => computeMinProcessingState(acc, nodeUserDataProcessingStateMap[key]), GraphNodeProcessingState.Finished);
	return result;
}

/**
 * Updates the UserData values that correspond to [[tube]]
 */
export function applyTubeToUserData(
	vertex: Vertex,
	tube: Readonly<Tube>,
	userDataInput?: StringIndexedInterface,
): StringIndexedInterface {
	let userData = userDataInput ?? wsi4.node.userData(vertex);
	userData = createUpdatedNodeUserData("tubeMaterialId", vertex, tube.tubeMaterialId, userData);
	userData = createUpdatedNodeUserData("tubeSpecificationId", vertex, tube.tubeSpecificationId, userData);
	return userData;
}
