import {
	tolerances,
} from "qrc:/js/lib/constants";
import {
	GeometryEntityType,
	SegmentType,
} from "qrc:/js/lib/generated/enum";
import {
	isStringIndexedInterface,
} from "qrc:/js/lib/generated/typeguard";
import {
	AssemblyMapEntry,
	BrepMapEntry,
} from "qrc:/js/lib/interfaces";

// Note: All functions in this module must conform to DocumentGraphHandler's script engine API.

export function isArray<T>(arr: unknown, isT: (t: unknown) => t is T): arr is Array<T> {
	if (!Array.isArray(arr)) {
		return false;
	}
	return arr.find(element => !isT(element)) === undefined;
}

export function isString(obj: unknown): obj is string {
	return typeof obj === "string";
}
export function isNumber(obj: unknown): obj is number {
	return typeof obj === "number";
}
export function isBoolean(obj: unknown): obj is boolean {
	return typeof obj === "boolean";
}
export function isObject(obj: unknown): obj is object {
	return typeof obj === "object";
}

/**
 * Check if an object contains the property and the type of the property value is T
 */
export function hasPropertyT<Obj extends object, T>(obj: Obj, property: string, isT: (t: unknown) => t is T): boolean {
	// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
	return isT((obj as any)[property]);
}

/**
 * Check if an object has an optional property and if it has it, if the type is right
 */
export function hasOptionalPropertyT<Obj extends object, T>(obj: Obj, property: string, isT: (t: unknown) => t is T): boolean {
	return hasPropertyT(obj, property, (t: unknown): t is undefined | T => t === undefined || isT(t));
}

/**
 * Check if arrays are equal
 */
function isEqualArray<T>(first: Array<T>, second: Array<T>): boolean {
	if (first.length !== second.length) {
		return false;
	}
	// eslint-disable-next-line @typescript-eslint/no-for-in-array
	for (const index in first) {
		if (!isEqual(first[index], second[index])) {
			return false;
		}
	}
	return true;
}

/**
 * Return keys of object with right type
 */
export function getKeysOfObject<T extends {}>(obj: T): Array<keyof T & string> {
	const keys = Object.keys(obj);
	return <Array<keyof T & string>>keys;
}

/**
 * Compare predicate for string
 */
function compareStrings(lhs: string, rhs: string) {
	if (lhs < rhs) {
		return -1;
	} else if (rhs < lhs) {
		return 1;
	} else {
		return 0;
	}
}
/**
 * Check if [[first]] and [[second]] are equal to each other
 * Note: Function types are not handled (see #1601)
 * Note: Objects are expected to have a valid (!= null) prototype with string representation "{}"
 */
export function isEqual<T>(first: T, second: T): boolean {
	if (typeof first !== typeof second) {
		return false;
	}
	if (first === undefined) {
		// second is undefined because of type check
		return true;
	}

	if (first === null) {
		// null is of type object so check for second is null
		return second === null;
	}
	if (second === null) {
		// null is of type object so check for first is null
		return first === null;
	}
	if (isNumber(first) || isBoolean(first) || isString(first)) {
		return first === second;
	}
	if (!isObject(first)) {
		return wsi4.throwError("first: wrong type");
	}
	if (!isObject(second)) {
		return wsi4.throwError("second: wrong type");
	}
	if (Array.isArray(first)) {
		if (!Array.isArray(second)) {
			return wsi4.throwError("second: not array");
		}
		return isEqualArray(first, second);
	}

	// There are Objects that behave differently from regular objects
	// Example: Qt's JavaScript representation of 64-bit integral values.
	// For these objects
	// - stringify() returns string (e. g. "42")
	// - isString() returns false
	// - isObject() returns true
	// - Object.keys() / getKeysOfObject() return an empty array respectively
	// As a result this function would return true for any two instances of this object leading to unexpected behaviour.
	// The following checks make the function more robust if used with obscure objects
	const propertiesMatchKeys = (obj: Object) => isEqualArray(Object.keys(obj).sort(compareStrings), Object.getOwnPropertyNames(obj).sort(compareStrings));
	if (!propertiesMatchKeys(first) || !propertiesMatchKeys(second)) {
		return wsi4.throwError("Object invalid");
	}

	const isValidPrototype = (obj: Object) => JSON.stringify(Object.getPrototypeOf(obj)) === "{}";
	if (!isValidPrototype(first) || !isValidPrototype(second)) {
		return wsi4.throwError("Object prototype invalid");
	}

	const emptyKeysMeansEmptyObject = (obj: Object) => (Object.keys(obj).length === 0) === (JSON.stringify(obj) === "{}");
	if (!emptyKeysMeansEmptyObject(first) || !emptyKeysMeansEmptyObject(second)) {
		return wsi4.throwError("Object invalid");
	}

	const keys = getKeysOfObject(first);
	if (keys.length !== Object.keys(second).length) {
		return false;
	}
	return keys.every(key => isEqual(first[key], second[key]));
}

/**
 * Function that does a JSON.parse() combined with a type check to make sure the parsed object is as expected
 */
export function parseJson<T>(input: string, isT: (t: unknown) => t is T): T | undefined {
	try {
		const value: unknown = JSON.parse(input);
		return isT(value) ? value : undefined;
	} catch (e) {
		// SyntaxError can be thrown be JSON.parse if the setting wasn't saved as a valid JSON.
		// Any other exception is unexpected and therefore rethrown.
		if (!(e instanceof SyntaxError)) {
			throw e;
		}
		return undefined;
	}
}

/**
 * Function that does a JSON.parse() combined with a type check, taking a default value that is returned in case the JSON.parse() was unsuccessful
 */
export function parseJsonDefault<T>(input: string, defaultValue: T, isT: (t: unknown) => t is T): T {
	const p = parseJson(input, isT);
	return p === undefined ? defaultValue : p;
}

export function readSetting<T>(key: string, defaultValue: T, isT: (t: unknown) => t is T): T {
	const json = wsi4.io.settings.read(key);
	return json === undefined ? defaultValue : parseJsonDefault(json, defaultValue, isT);
}

export function writeSetting(key: string, value: unknown): void {
	if (value !== undefined && value !== null) {
		wsi4.io.settings.write(key, JSON.stringify(value));
	} else {
		wsi4.io.settings.remove(key);
	}
}
export function isNonNegativeNumber(n: number): boolean {
	if (!Number.isFinite(n)) {
		wsi4.util.warn("validNonNegative(): expecting finite number; got " + String(n));
		return false;
	}
	if (n < 0) {
		wsi4.util.warn("validNonNegative(): expecting non-negative number");
		return false;
	}
	return true;
}

// Note: Expecting tableData to be sorted
// If threshold condition fails for last array value falling back to last array value
export function getRowForThreshold<Row>(tableData: Array<Row>, cond: (arg: Row) => boolean): Row | undefined {
	if (tableData.length === 0) {
		return undefined;
	}

	if (!cond(tableData[tableData.length - 1]!)) {
		return tableData[tableData.length - 1];
	}
	return tableData.find(cond);
}

export function boxIsEmpty(box: Box2 | Box3): boolean {
	return box.lower.entries[0] > box.upper.entries[0];
}

function bbDimensionN(boundingBox: Box2 | Box3, dim: number): number {
	assertDebug(() => !boxIsEmpty(boundingBox), "Box is empty.");
	return boundingBox.upper.entries[dim]! - boundingBox.lower.entries[dim]!;
}

// compute x-dimension of bounding box
export function bbDimensionX(boundingBox: Box2 | Box3): number {
	return bbDimensionN(boundingBox, 0);
}

// compute y-dimension of bounding box
export function bbDimensionY(boundingBox: Box2 | Box3): number {
	return bbDimensionN(boundingBox, 1);
}

// compute z-dimension of bounding box
export function bbDimensionZ(boundingBox: Box3): number {
	return bbDimensionN(boundingBox, 2);
}

// Flattens assembly tree to id -> assembly map. Assembly is slightly modified to match requirements of classes processing the assembings (cf. joiningView, assemblyView)
// globalParentCosys (optional): if set, all assemblies will be interpreted as childs of this coordinate system
export function createAssemblyMap(assembly: Assembly, globalParentCosys?: CoordinateSystem3): Array<AssemblyMapEntry> {
	const recursiveAddToArray = (array: Array<AssemblyMapEntry>, asm: Assembly, parentAsm?: Assembly) => {
		const id = wsi4.util.toKey(asm);

		// Skip duplicates (can't explain why they occur though)
		if (array.find(element => element.id === id) !== undefined) {
			return;
		}

		const wcs = (() => {
			const childCosys = wsi4.geo.assembly.worldCoordinateSystem(asm);
			if (globalParentCosys === undefined) {
				return childCosys;
			} else {
				return wsi4.geo.util.applyCoordinateSystem(globalParentCosys, childCosys);
			}
		})();

		const wcsTransformation = wsi4.geo.util.toProjectiveTransformationMatrix(wcs);
		const subAssemblies = wsi4.geo.assembly.subAssemblies(asm);
		const subAssemblyIds = subAssemblies.map((subAsm: Assembly) => wsi4.util.toKey(subAsm));
		const brep = wsi4.geo.assembly.brep(asm);
		array.push({
			id: id,
			assembly: {
				wcsTransformation: wcsTransformation.entries,
				parentId: parentAsm === undefined ? undefined : wsi4.util.toKey(parentAsm),
				brepId: brep === undefined ? undefined : wsi4.util.toKey(brep),
				subAssemblyIds: subAssemblyIds,
			},
		});

		for (const subAsm of subAssemblies) {
			recursiveAddToArray(array, subAsm, asm);
		}
	};

	const result: Array<AssemblyMapEntry> = [];
	recursiveAddToArray(result, assembly);
	return result;
}

export function createBrepMapFromAssembly(assembly: Assembly): Array<BrepMapEntry> {
	const recursiveAddToMap = (array: Array<BrepMapEntry>, assembly: Assembly) => {
		for (const subAssembly of wsi4.geo.assembly.subAssemblies(assembly)) {
			recursiveAddToMap(array, subAssembly);
		}
		const brep = wsi4.geo.assembly.brep(assembly);
		if (brep === undefined) {
			return;
		}
		if (array.find(element => element.id === wsi4.util.toKey(brep)) !== undefined) {
			return;
		}
		// Note: JSON.parse() call is moved to browser as Qt's implementation crashes for large input data (see #1575)
		array.push({
			id: wsi4.util.toKey(brep),
			brepJson: wsi4.geo.brep.toPolyJson(brep),
		});
	};

	const result: Array<BrepMapEntry> = [];
	recursiveAddToMap(result, assembly);
	return result;
}

/**
 * Turn a rootId into a Vertex, throwing a wsi4.throwError if it fails.
 */
export function rootIdToVertex(rootId: GraphNodeRootId): Vertex {
	const id = wsi4.node.vertexFromRootId(rootId);
	if (id === undefined) {
		return wsi4.throwError("Cannot get Vertex for root id " + JSON.stringify(rootId));
	}
	return id;
}

/**
 * Get filename of path with extension
 */
export function fileName(filePath: string): string {
	return filePath.substring(filePath.lastIndexOf("/") + 1);
}

export function defaultPngResolution(): Resolution {
	// For now the values are reduced to speed up creation of graph representation; previous values were 800 x 600
	return {
		width: 400,
		height: 300,
	};
}

export function renderDefaultPng(assembly: Assembly, resolution: Resolution): ArrayBuffer {
	const camera = wsi4.geo.assembly.computeDefaultCamera(assembly);
	return wsi4.geo.assembly.renderIntoPng(assembly, camera, resolution);
}

export function asyncRenderDefaultPng(assembly: Assembly, resolution: Resolution): ArrayBufferFuture {
	const camera = wsi4.geo.assembly.computeDefaultCamera(assembly);
	return wsi4.geo.assembly.asyncRenderIntoPng(assembly, camera, resolution);
}

export function computeSegmentLength(segment: Segment): number {
	switch (segment.type) {
		case SegmentType.line: {
			return Math.sqrt(Math.pow(segment.content.to.entries[0] - segment.content.from.entries[0], 2) + Math.pow(segment.content.to.entries[1] - segment.content.from.entries[1], 2));
		}
		case SegmentType.arc: {
			return wsi4.throwError("Segment length not implemented for arc segments");
		}
	}
}

function csvDelimiter(locale: string) {
	const decimalPoint = wsi4.locale.decimalPoint(locale);
	return decimalPoint === "." ? "," : ";";
}

function detectCsvDelimiter(input: string, locale: string): string {
	const endLineIndex = input.indexOf("\n");
	const rIndex = input.indexOf("\r");
	const firstLineEnd = endLineIndex === -1 && rIndex === -1 ? input.length : (endLineIndex === -1 || rIndex === -1 ? Math.max(endLineIndex, rIndex) : Math.min(endLineIndex, rIndex));
	const firstLine = input.substring(0, firstLineEnd);
	if (firstLine.length <= 0) {
		return csvDelimiter(locale);
	}
	if (firstLine.includes(";")) {
		return ";";
	} else if (firstLine.includes(",")) {
		return ",";
	}
	return csvDelimiter(locale);
}

/**
 * Parse CSV formatted string
 * @param input CSV formatted string
 * @returns Array of objects if successful; undefined else
 *
 * Note: CSV-delimiter is `','`
 * Note: First row of CSV defines the column keys.
 *       Each subsequent row of the CSV is converted to a StringIndexedInterface where the indices are defined by the column keys.
 */
export function parseCsv(input: string, locale: string): StringIndexedInterface[] | undefined {
	const delimiter = detectCsvDelimiter(input, locale);
	const rows = wsi4.util.parseCsv(input.trim(), delimiter);
	if (rows === undefined || rows.length === 0) {
		return undefined;
	}
	const keys = rows.shift();
	if (keys === undefined) {
		return undefined;
	}
	if (rows.some(row => row.length !== keys.length)) {
		return undefined;
	}
	return rows.map(row => keys.reduce((acc: StringIndexedInterface, key, index) => {
		acc[key] = row[index];
		return acc;
	}, {}));
}

/**
 * Create CSV formatted string from an array of rows where each row consists of strings and/or numbers
 */
export function toCsvString(rows: (string | number | boolean | undefined)[][], locale: string, precision = 6): string {
	return rows.map(row => row.map(value => {
		const str: String = (() => {
			if (isString(value)) {
				// Escape double quotes according to https://datatracker.ietf.org/doc/html/rfc4180#section-2
				return "\"" + value.replace(/"/g, "\"\"") + "\"";
			} else if (isNumber(value)) {
				return wsi4.locale.floatToString(value, locale, precision);
			} else if (isBoolean(value)) {
				return value ? "true" : "false";
			} else if (value === undefined) {
				return "";
			} else {
				wsi4.throwError("Unexpected type for CSV cell");
			}
		})();
		return str;
	})
		.join(csvDelimiter(locale)))
		// Using linebreaks according to https://datatracker.ietf.org/doc/html/rfc4180#section-2
		.join("\r\n");
}

/**
 * Compare two objects lexicographically
 */
export function lexicographicObjectLess<Obj extends {}>(lhs: Obj, rhs: Obj): number {
	const key = getKeysOfObject(lhs)
		.sort(compareStrings)
		.find(key => lhs[key] !== rhs[key]);
	return key === undefined ? 0 : lhs[key] < rhs[key] ? -1 : 1;
}

export function computeBox3(twoDimRep: TwoDimRepresentation, thickness: number): Box3 {
	const bbox2 = wsi4.cam.util.boundingBox2(twoDimRep);
	return {
		lower: {
			entries: [
				bbox2.lower.entries[0],
				bbox2.lower.entries[1],
				0.,
			],
		},
		upper: {
			entries: [
				bbox2.upper.entries[0],
				bbox2.upper.entries[1],
				thickness,
			],
		},
	};
}

export function assert(condition: boolean, msg?: string): asserts condition {
	if (!condition) {
		wsi4.throwError("Assertion failure" + (msg === undefined ? "" : ": " + msg));
	}
}

/**
 * Assert a condition that is *not* enforced by the TypeScript compiler.
 * The check is only performed when evaluated by a debug build.
 */
export function assertDebug(func: () => boolean, msg?: string): void {
	if (wsi4.util.isDebug()) {
		assert(func(), msg);
	}
}

export function recursiveSubAssemblies(assembly: Assembly): Assembly[] {
	return wsi4.geo.assembly.subAssemblies(assembly)
		.reduce((acc: Assembly[], asm) => {
			acc.push(asm);
			acc.concat(recursiveSubAssemblies(asm));
			return acc;
		}, []);
}

export function computeEntityFaceArea(rootAssembly: Assembly, entity: GeometryEntity): number {
	switch (entity.descriptor.type) {
		case GeometryEntityType.edge: return 0;
		case GeometryEntityType.face: {
			const assembly = wsi4.geo.assembly.resolvePath(rootAssembly, entity.assemblyPath);
			assert(assembly !== undefined, "Expecting valid assembly path");
			const brep = wsi4.geo.assembly.brep(assembly);
			assert(brep !== undefined, "Expecting valid brep");
			return wsi4.geo.brep.faceArea(brep, entity.descriptor.content.value);
		}
	}
}

export type ElisionMode = "none" | "left" | "middle" | "right";

export function isElisionMode(arg: unknown): arg is ElisionMode {
	const values: {[key in ElisionMode]: undefined} = {
		none: undefined,
		left: undefined,
		middle: undefined,
		right: undefined,
	};
	return isString(arg) && getKeysOfObject(values)
		.some(key => key === arg);
}

const elideFnMap: {[key in ElisionMode]: (arg: string, threshold: number) => string} = {
	none: arg => arg,
	left: (arg, threshold) => arg.substring(arg.length - threshold, arg.length),
	middle: (arg, threshold) => arg.substring(0, Math.ceil(Math.min(arg.length, threshold) / 2)) + arg.substring(arg.length - Math.floor(Math.min(arg.length, threshold) / 2), arg.length),
	right: (arg, threshold) => arg.substring(0, threshold),
};

export function elide(arg: string, mode: ElisionMode, threshold: number): string {
	return elideFnMap[mode](arg, threshold);
}

export function computeNameMaxLengthLowerBound(numNames: number): number {
	return 1 + numNames.toString().length;
}

function makeUnique(nameInput: string, existingNames: readonly Readonly<string>[], maxLength: number): string {
	const maxLengthLowerBound = computeNameMaxLengthLowerBound(1 + existingNames.length);
	assert(
		maxLength >= maxLengthLowerBound,
		"Pre-condition violated:  maxLength must be >= " + maxLengthLowerBound.toString() + "; actual:  " + maxLength.toString(),
	);

	const newNameBase = (() => {
		const numberIndexPosition = nameInput.search(/_[0-9]+$/);
		if (numberIndexPosition === -1) {
			return nameInput;
		} else {
			return nameInput.substring(0, numberIndexPosition);
		}
	})();

	for (let i = 0; i <= existingNames.length; ++i) {
		const suffix = "_" + i.toFixed(0);
		assert(
			suffix.length <= maxLength,
			"Suffix \"" + suffix + "\" exceeds max size of " + maxLength.toString(),
		);

		const newName = newNameBase.slice(0, maxLength - suffix.length) + suffix;
		if (existingNames.every(name => name !== newName)) {
			return newName;
		}
	}

	wsi4.throwError("Should not be reached");
}

export interface NameLengthLimitParams {
	elisionMode: ElisionMode;
	maxLength: number;
}

/**
 * Map inputNames to potentially modified values so the resulting list of values holds unique values
 *
 * Optionally a max name length can be enforced by submitting the respective ElisionMode and the maximum length.
 *
 * In case of name collisions the first occurence is kept as is.  All following occurences are made unique by appending
 * an underscore and a sufficient numeric suffix.
 *
 * Pre-condition:  If names should (potentially) be elided, the max name length must not be smaller than the value
 * returned by computeNameMaxLengthLowerBound() for the sum of the lengths of both input containers added.
 *
 * Post-condition:  All names in the resulting container are unique and (if requested) whithin the max length.
 */
export function computeUniqueNames(
	inputNames: readonly Readonly<string>[],
	existingNamesInput: readonly Readonly<string>[] = [],
	lengthLimitParams: Readonly<NameLengthLimitParams> | undefined = undefined,
): string[] {
	const [
		elisionMode,
		maxLength,
	] = ((): [ElisionMode, number] => {
		if (lengthLimitParams === undefined || lengthLimitParams.elisionMode === "none") {
			return [
				"none",
				Number.MAX_SAFE_INTEGER,
			];
		} else {
			const maxLengthLowerBound = computeNameMaxLengthLowerBound(inputNames.length + existingNamesInput.length);
			assert(
				lengthLimitParams.maxLength >= maxLengthLowerBound,
				"Pre-condition violated:  maxLength must be >= " + maxLengthLowerBound.toString() + "; actual:  " + lengthLimitParams.maxLength.toString(),
			);
			return [
				lengthLimitParams.elisionMode,
				lengthLimitParams.maxLength,
			];
		}
	})();

	const existingNames = Array.from(existingNamesInput);
	const result: string[] = [];

	inputNames.forEach(inputName => {
		const uniqueName = (() => {
			const elidedName = elide(inputName, elisionMode, maxLength);
			if (existingNames.some(n => n === elidedName)) {
				return makeUnique(elidedName, existingNames, maxLength);
			} else {
				return elidedName;
			}
		})();
		existingNames.push(uniqueName);
		result.push(uniqueName);
	});

	return result;
}

/**
 * Check if thickness are equal
 * They are equal, if the difference is smaller than tolerance
 */
export function thicknessEquivalence(first: number, second: number): boolean {
	return Math.abs(first - second) < tolerances.thickness;
}

export function cleanFileName(input: string, replacement = "_"): string {
	const illegalChars = /[/:*?"<>|\n\r\\]/g;
	return input.replace(illegalChars, replacement)
		.trim();
}

export function cleanRelativeDirPath(input: string, replacement = "_"): string {
	const illegalChars = /[:*?"<>|\n\r]/g;
	const leadingSlash = /^\//;
	const trailingSlash = /\/$/;
	return input.replace(illegalChars, replacement)
		.replace(leadingSlash, "")
		.replace(trailingSlash, "")
		.trim();
}

export function cleanAbsoluteDirPath(input: string, replacement = "_"): string {
	const firstCharIllegalChars = /[:*?"<>|\n\r]/;
	const secondCharIllegalChars = /[*?"<>|\n\r]/;
	const tailIllegalChars = /[:*?"<>|\n\r]/g;
	const trailingSlash = /\/$/;

	const firstChar = input.substring(0, 1);
	const secondChar = input.substring(1, 2);
	const tail = input.substring(2);

	return firstChar.replace(firstCharIllegalChars, replacement) +
		secondChar.replace(secondCharIllegalChars, replacement) +
		tail.replace(tailIllegalChars, replacement)
			.replace(trailingSlash, "")
			.trim();
}

/**
 * Compute array where each element is part of each input array
 *
 * Note:  The order of the resulting array is undefined.
 */
export function computeArrayIntersection<T>(
	input: readonly T[][],
	equal: (lhs: Readonly<T>, rhs: Readonly<T>) => boolean = isEqual): Readonly<T>[] {
	return input.reduce(
		(acc: T[], arr) => {
			acc.push(...arr);
			return acc;
		},
		[])
		.filter((lhs, index, self) => index === self.findIndex(rhs => equal(lhs, rhs)) && input.every(arr => arr.some(rhs => equal(lhs, rhs))));
}

/**
 * Instantiate exhaustive tuple with all values of a string union type
 *
 * Implementation from https://stackoverflow.com/a/55266531
 *
 * Usage:
 *
 * ```
 * type SomeType = "foo" | "bar" | "baz";
 *
 * // Ok:
 * const values0 = exhaustiveStringTuple<SomeType>()("foo", "bar", "baz");
 *
 * interface SomeInterface {
 * 	foo: int;
 * 	bar: string;
 * 	baz: string[];
 * }
 *
 * // Ok:
 * const keys0 = exhaustiveStringTuple<keyof SomeInterface>()("foo", "bar", baz);
 *
 * // Error ("baz" is missing):
 * const values1 = exhaustiveStringTuple<SomeType>()("foo", "bar");
 *
 * // Error ("foobar" is not a SomeType):
 * const values2 = exhaustiveStringTuple<SomeType>()("foo", "bar", "baz", "foobar");
 *
 * // Error ("baz" is missing):
 * const keys1 = exhaustiveStringTuple<keyof SomeInterface>()("foo", "bar");
 *
 * // Error (empty input array):
 * const keys2 = exhaustiveStringTuple<keyof SomeInterface>()();
 * ```
 */
type AtLeastOne<T> = [T, ...T[]];

export function exhaustiveStringTuple<T extends string>() {
	// This function's return type needs to be deduced depending on the input type T
	/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
	return <L extends AtLeastOne<T>>(
		...x: L extends any ? (
			Exclude<T, L[number]> extends never ? L : Exclude<T, L[number]>[]
		) : never
	) => x;
	/* eslint-enable @typescript-eslint/explicit-module-boundary-types */
}

/**
 * Convenience function to implement typeguard for an interface T
 *
 * The consistency of the implementation is enforced by the compiler.
 *
 * Example:
 *
 * interface I {
 * 	foo: number;
 * 	bar: string;
 * }
 *
 * // Ok:
 * function isI(arg: unknown): arg is I {
 * 	return isInstanceOf<I>(arg, {
 * 		foo: isString,
 * 		bar: isNumber,
 * 	});
 * }
 *
 * // Error (typeguard for bar is missing):
 * function isI(arg: unknown): arg is I {
 * 	return isInstanceOf<I>(arg, {
 * 		foo: isString,
 * 	});
 * }
 *
 * // Error (typeguard for bar has wrong type):
 * function isI(arg: unknown): arg is I {
 * 	return isInstanceOf<I>(arg, {
 * 		foo: isString,
 * 		bar: isString,
 * 	});
 * }
 *
 * // Error (baz is not part of I):
 * function isI(arg: unknown): arg is I {
 * 	return isInstanceOf<I>(arg, {
 * 		foo: isString,
 * 		bar: isNumber,
 * 		baz: isBoolean,
 * 	});
 * }
 */
export function isInstanceOf<T>(arg: unknown, typeGuardMap: {[index in keyof Required<T>]: (arg: unknown) => arg is T[index]}): arg is T {
	return isStringIndexedInterface(arg) && getKeysOfObject(typeGuardMap)
		.every(key => typeGuardMap[key](arg[key]));
}
