/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { Base } from "../types/base.mjs";
import {
	isArray,
	isInteger,
	isObject,
	isPrimitive,
	isString,
} from "../types/is.mjs";
import { Stack } from "../types/stack.mjs";
import {
	validateInteger,
	validateBoolean,
	validateString,
} from "../types/validate.mjs";

export { Pathfinder, DELIMITER, WILDCARD };

/**
 * path separator
 *
 * @private
 * @type {string}
 */
const DELIMITER = ".";

/**
 * @private
 * @type {string}
 */
const WILDCARD = "*";

/**
 * Pathfinder is a class to find a path to an object.
 *
 * With the help of the pathfinder, values can be read and written from an object construct.
 *
 * ```
 * new Pathfinder({
 * a: {
 *     b: {
 *         f: [
 *             {
 *                 g: false,
 *             }
 *         ],
 *     }
 * }
 * }).getVia("a.b.f.0.g"); // ↦ false
 * ```
 *
 * if a value is not present or has the wrong type, a corresponding exception is thrown.
 *
 * ```
 * new Pathfinder({}).getVia("a.b.f.0.g"); // ↦ Error
 * ```
 *
 * The `Pathfinder.exists()` method can be used to check whether access to the path is possible.
 *
 * ```
 * new Pathfinder({}).exists("a.b.f.0.g"); // ↦ false
 * ```
 *
 * pathfinder can also be used to build object structures. to do this, the `Pathfinder.setVia()` method must be used.
 *
 * ```
 * obj = {};
 * new Pathfinder(obj).setVia('a.b.0.c', true); // ↦ {a:{b:[{c:true}]}}
 * ```
 *
 * @example /examples/libraries/pathfinder/example-1/ Example 1
 * @example /examples/libraries/pathfinder/example-2/ Example 2
 *
 * @license AGPLv3
 * @since 1.4.0
 * @copyright Volker Schukai
 * @summary A path resolution utility for reading nested values from complex objects and records.
 */
class Pathfinder extends Base {
	/**
	 * Creates a new instance of the constructor.
	 *
	 * @param {object} object - The object parameter for the constructor.
	 *
	 * @throws {Error} Throws an error if the provided object parameter is a simple type.
	 */
	constructor(object) {
		super();

		if (isPrimitive(object)) {
			throw new Error("the parameter must not be a simple type");
		}

		this.object = object;
		this.wildCard = WILDCARD;
	}

	/**
	 * set wildcard
	 *
	 * @param {string} wildcard
	 * @return {Pathfinder}
	 * @since 1.7.0
	 */
	setWildCard(wildcard) {
		validateString(wildcard);
		this.wildCard = wildcard;
		return this;
	}

	/**
	 *
	 * @param {string|array} path
	 * @since 1.4.0
	 * @return {*}
	 * @throws {TypeError} unsupported type
	 * @throws {Error} the journey is not at its end
	 * @throws {TypeError} value is not a string
	 * @throws {TypeError} value is not an integer
	 * @throws {Error} unsupported action for this data type
	 */
	getVia(path) {
		return getValueViaPath.call(this, this.object, path);
	}

	/**
	 *
	 * @param {string|array} path
	 * @param {*} value
	 * @return {Pathfinder}
	 * @since 1.4.0
	 * @throws {TypeError} unsupported type
	 * @throws {TypeError} value is not a string
	 * @throws {TypeError} value is not an integer
	 * @throws {Error} unsupported action for this data type
	 */
	setVia(path, value) {
		setValueViaPath.call(this, this.object, path, value);
		return this;
	}

	/**
	 * Delete Via Path
	 *
	 * @param {string|array} path
	 * @return {Pathfinder}
	 * @since 1.6.0
	 * @throws {TypeError} unsupported type
	 * @throws {TypeError} value is not a string
	 * @throws {TypeError} value is not an integer
	 * @throws {Error} unsupported action for this data type
	 */
	deleteVia(path) {
		deleteValueViaPath.call(this, this.object, path);
		return this;
	}

	/**
	 *
	 * @param {string|array} path
	 * @return {bool}
	 * @throws {TypeError} unsupported type
	 * @throws {TypeError} value is not a string
	 * @throws {TypeError} value is not an integer
	 * @since 1.4.0
	 */
	exists(path) {
		try {
			getValueViaPath.call(this, this.object, path, true);
			return true;
		} catch (e) {}

		return false;
	}
}

/**
 *
 * @param {*} subject
 * @param {string|array} path
 * @param {boolean} check
 * @return {Map}
 * @throws {TypeError} unsupported type
 * @throws {Error} the journey is not at its end
 * @throws {Error} unsupported action for this data type
 * @private
 */
function iterate(subject, path, check) {
	if (check === undefined) {
		check = false;
	}
	validateBoolean(check);

	const result = new Map();

	if (isArray(path)) {
		path = path.join(DELIMITER);
	}

	if (isObject(subject) || isArray(subject)) {
		for (const [key, value] of Object.entries(subject)) {
			result.set(key, getValueViaPath.call(this, value, path, check));
		}
	} else {
		const key = path.split(DELIMITER).shift();
		result.set(key, getValueViaPath.call(this, subject, path, check));
	}

	return result;
}

/**
 *
 * @param subject
 * @param path
 * @param check
 * @return {V|*|Map}
 * @throws {TypeError} unsupported type
 * @throws {Error} the journey is not at its end
 * @throws {Error} unsupported action for this data type
 */
function getValueViaPath(subject, path, check) {
	if (check === undefined) {
		check = false;
	}
	validateBoolean(check);

	if (!(isArray(path) || isString(path))) {
		throw new Error(
			"type error: a path must be a string or an array in getValueViaPath",
		);
	}

	let parts;
	if (isString(path)) {
		if (path === "") {
			return subject;
		}

		parts = path.split(DELIMITER);
	}

	let current = parts.shift();

	if (current === this.wildCard) {
		return iterate.call(this, subject, parts.join(DELIMITER), check);
	}

	if (isObject(subject) || isArray(subject)) {
		let anchor;
		if (subject instanceof Map || subject instanceof WeakMap) {
			anchor = subject.get(current);
		} else if (subject instanceof Set || subject instanceof WeakSet) {
			current = parseInt(current);
			validateInteger(current);
			anchor = [...subject]?.[current];
		} else if (typeof WeakRef === "function" && subject instanceof WeakRef) {
			throw Error("unsupported action for this data type (WeakRef)");
		} else if (isArray(subject)) {
			current = parseInt(current);
			validateInteger(current);
			anchor = subject?.[current];
		} else {
			anchor = subject?.[current];
		}

		if (isObject(anchor) || isArray(anchor)) {
			return getValueViaPath.call(this, anchor, parts.join(DELIMITER), check);
		}

		if (parts.length > 0) {
			throw Error(`the journey is not at its end (${parts.join(DELIMITER)})`);
		}

		if (check === true) {
			const descriptor = Object.getOwnPropertyDescriptor(
				Object.getPrototypeOf(subject),
				current,
			);

			if (!subject.hasOwnProperty(current) && descriptor === undefined) {
				throw Error("unknown value " + current);
			}
		}

		return anchor;
	}

	throw TypeError(`unsupported type ${typeof subject} for path ${path}`);
}

/**
 *
 * @param {object} subject
 * @param {string|array} path
 * @param {*} value
 * @return {void}
 * @throws {TypeError} unsupported type
 * @throws {TypeError} unsupported type
 * @throws {Error} the journey is not at its end
 * @throws {Error} unsupported action for this data type
 * @private
 */
function setValueViaPath(subject, path, value) {
	if (!(isArray(path) || isString(path))) {
		throw new Error("type error: a path must be a string or an array");
	}

	let parts;
	if (isArray(path)) {
		if (path.length === 0) {
			return;
		}

		parts = path;
	} else {
		parts = path.split(DELIMITER);
	}

	let last = parts.pop();
	const subpath = parts.join(DELIMITER);

	const stack = new Stack();
	let current = subpath;
	while (true) {
		try {
			getValueViaPath.call(this, subject, current, true);
			break;
		} catch (e) {}

		stack.push(current);
		parts.pop();
		current = parts.join(DELIMITER);

		if (current === "") break;
	}

	while (!stack.isEmpty()) {
		current = stack.pop();
		let obj = {};

		if (!stack.isEmpty()) {
			const n = stack.peek().split(DELIMITER).pop();
			if (isInteger(parseInt(n))) {
				obj = [];
			}
		}

		setValueViaPath.call(this, subject, current, obj);
	}

	const anchor = getValueViaPath.call(this, subject, subpath);

	if (!(isObject(subject) || isArray(subject))) {
		throw TypeError(`unsupported type: ${typeof subject} in setValueViaPath`);
	}

	if (anchor instanceof Map || anchor instanceof WeakMap) {
		anchor.set(last, value);
	} else if (anchor instanceof Set || anchor instanceof WeakSet) {
		anchor.append(value);
	} else if (typeof WeakRef === "function" && anchor instanceof WeakRef) {
		throw Error("unsupported action for this data type in setValueViaPath");
	} else if (isArray(anchor)) {
		last = parseInt(last);
		validateInteger(last);
		assignProperty(anchor, "" + last, value);
	} else {
		assignProperty(anchor, last, value);
	}
}

/**
 * @private
 * @param {object} object
 * @param {string} key
 * @param {*} value
 */
function assignProperty(object, key, value) {
	if (!object.hasOwnProperty(key)) {
		object[key] = value;
		return;
	}

	if (value === undefined) {
		delete object[key];
	}

	object[key] = value;
}

/**
 *
 * @param {object} subject
 * @param {string} path
 * @return {void}
 * @throws {TypeError} unsupported type
 * @throws {TypeError} unsupported type
 * @throws {Error} the journey is not at its end
 * @throws {Error} unsupported action for this data type
 * @license AGPLv3
 * @since 1.6.0
 * @private
 */
function deleteValueViaPath(subject, path) {
	if (!(isArray(path) || isString(path))) {
		throw new Error(
			"type error: a path must be a string or an array in deleteValueViaPath",
		);
	}

	let parts;
	if (isArray(path)) {
		if (path.length === 0) {
			return;
		}

		parts = path;
	} else {
		parts = path.split(DELIMITER);
	}

	let last = parts.pop();
	const subPath = parts.join(DELIMITER);

	const anchor = getValueViaPath.call(this, subject, subPath);

	if (anchor instanceof Map) {
		anchor.delete(last);
	} else if (
		anchor instanceof Set ||
		anchor instanceof WeakMap ||
		anchor instanceof WeakSet ||
		(typeof WeakRef === "function" && anchor instanceof WeakRef)
	) {
		throw Error("unsupported action for this data type in deleteValueViaPath");
	} else if (isArray(anchor)) {
		last = parseInt(last);
		validateInteger(last);
		delete anchor[last];
	} else {
		delete anchor[last];
	}
}
