/**
 * 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 { isFunction, isObject, isString } from "../types/is.mjs";
import { validateString } from "../types/validate.mjs";
import { clone } from "../util/clone.mjs";
import { DELIMITER, Pathfinder, WILDCARD } from "./pathfinder.mjs";

export { buildMap, PARENT, assembleParts };

/**
 * @type {string}
 */
const PARENT = "^";

/**
 * Maps can be easily created from data objects with the help of the function `buildMap()`.
 *
 * The path can be specified as either a simple definition a.b.c or a template ${a.b.c}.
 * Key and value can be either a definition or a template. The key does not have to be defined.
 * The templates determine the appearance of the keys and the value of the map. Either a single value
 * id can be taken or a composite key ${id} ${name} can be used.
 *
 * If you want to access the values of the parent data set, you have to use the ^ character, for example, ${id} ${^.name}.
 *
 * @externalExample ../../example/data/buildmap.mjs
 *
 * @param {*} subject - The data object from which the map will be created
 * @param {string|Monster.Data~exampleSelectorCallback} selector - The path to the data object, or a callback that returns a map.
 * @param {string} [valueTemplate] - A template for the value of the map.
 * @param {string} [keyTemplate] - A template for the key of the map.
 * @param {Monster.Data~exampleFilterCallback} [filter] - A callback function to filter out values.
 * @return {*} - The created map.
 * @throws {TypeError} - If the value is neither a string nor a function.
 * @throws {TypeError} - If the selector callback does not return a map.
 **/
function buildMap(subject, selector, valueTemplate, keyTemplate, filter) {
	return assembleParts(subject, selector, filter, function (v, k, m) {
		k = build(v, keyTemplate, k);
		v = build(v, valueTemplate);
		this.set(k, v);
	});
}

/**
 * The assembleParts function is a private function that helps in building a map from a subject object based on a provided
 * selector. The selector can either be a string or a callback function. This function is meant to be used as a
 * helper function by other functions in the module.
 *
 * The function takes four parameters:
 *
 * subject: The subject object from which the map is to be built
 * selector: The selector to determine the structure of the map. It can be a string or a callback function.
 * filter (optional): A callback function that can be used to filter values based on some criteria.
 * callback: A function to be called for each element in the map.
 * If the selector parameter is a callback function, it is executed passing the subject as its argument,
 * and the resulting value must be an instance of Map. Otherwise, if the selector parameter is a string,
 * buildFlatMap is called to build a flat map with keys and values extracted from the subject object based on the selector.
 *
 * If the filter parameter is provided, it will be used to filter out certain elements from the map, based on some
 * criteria. The callback will be passed the value, key, and map object, and if it returns false, the element will be skipped.
 *
 * For each element in the map, the callback function is called with the following parameters:
 *
 * v: The value of the element
 * k: The key of the element
 * m: The map object
 * The function returns a new map with the processed values. If map is not an instance of Map, an empty map will be returned.
 *
 * Example Usage:
 *
 * ```javascript
 * const obj = {
 *   name: "John",
 *   age: 30,
 *   address: {
 *     city: "New York",
 *     state: "NY",
 *     country: "USA",
 *   },
 * };
 *
 * const selector = "address";
 *
 * const map = assembleParts(obj, selector, null, function (v, k, m) {
 *   this.set(k, v);
 * });
 *
 * console.log(map);
 * // Output: Map(3) {
 * //   "address.city" => "New York",
 * //   "address.state" => "NY",
 * //   "address.country" => "USA"
 * // }
 * ```
 *
 *
 * @private
 * @param {*} subject - The subject object from which the map is to be built.
 * @param {string|Monster.Data~exampleSelectorCallback} selector - The selector to determine the structure of the map. It can be a string or a callback function.
 * @param {Monster.Data~exampleFilterCallback} [filter] - A callback function that can be used to filter values based on some criteria.
 * @param {function} callback - A function to be called for each element in the map.
 * @return {Map} - A new map with the processed values.
 * @throws {TypeError} - When selector is neither a string nor a function.
 */
function assembleParts(subject, selector, filter, callback) {
	const result = new Map();

	let map;
	if (isFunction(selector)) {
		map = selector(subject);
		if (!(map instanceof Map)) {
			throw new TypeError("the selector callback must return a map");
		}
	} else if (isString(selector)) {
		map = new Map();
		buildFlatMap.call(map, subject, selector);
	} else {
		throw new TypeError("selector is neither a string nor a function");
	}

	if (!(map instanceof Map)) {
		return result;
	}

	map.forEach((v, k, m) => {
		if (isFunction(filter)) {
			if (filter.call(m, v, k) !== true) return;
		}

		callback.call(result, v, k, m);
	});

	return result;
}

/**
 * @private
 * @param subject
 * @param selector
 * @param key
 * @param parentMap
 * @return {*}
 */
function buildFlatMap(subject, selector, key, parentMap) {
	const currentMap = new Map();

	const resultLength = this.size;

	if (key === undefined) key = [];

	const parts = selector.split(DELIMITER);
	let current = "";
	const currentPath = [];
	do {
		current = parts.shift();
		currentPath.push(current);

		if (current === WILDCARD) {
			const finder = new Pathfinder(subject);
			let map;

			try {
				map = finder.getVia(currentPath.join(DELIMITER));
			} catch (e) {
				const a = e;
				map = new Map();
			}

			for (const [k, o] of map) {
				const copyKey = clone(key);

				currentPath.map((a) => {
					copyKey.push(a === WILDCARD ? k : a);
				});

				const kk = copyKey.join(DELIMITER);
				const sub = buildFlatMap.call(
					this,
					o,
					parts.join(DELIMITER),
					copyKey,
					o,
				);

				if (isObject(sub) && parentMap !== undefined) {
					sub[PARENT] = parentMap;
				}

				currentMap.set(kk, sub);
			}
		}
	} while (parts.length > 0);

	// no set in child run
	if (resultLength === this.size) {
		for (const [k, o] of currentMap) {
			this.set(k, o);
		}
	}

	return subject;
}

/**
 * With the help of this filter callback, values can be filtered out. Only if the filter function returns true, the value is taken for the map.
 *
 * @callback Monster.Data~exampleFilterCallback
 * @param {*} value Value
 * @param {string} key  Key
 * @see {@link Monster.Data.buildMap}
 */

/**
 * Alternatively to a string selector a callback can be specified. this must return a map.
 *
 * @example
 * import {buildMap} from '@schukai/monster/source/data/buildmap.mjs';
 *
 * let obj = {
 *                "data": [
 *                    {
 *                        "id": 10,
 *                        "name": "Cassandra",
 *                        "enrichment": {
 *                            variants: [
 *                                {
 *                                    sku: 1, label: "XXS", price: [
 *                                        {vk: '12.12 €'},
 *                                        {vk: '12.12 €'}
 *                                    ]
 *                                },
 *                                {
 *                                    sku: 2, label: "XS", price: [
 *                                        {vk: '22.12 €'},
 *                                        {vk: '22.12 €'}
 *                                    ]
 *                                },
 *                                {
 *                                    sku: 3, label: "S", price: [
 *                                        {vk: '32.12 €'},
 *                                        {vk: '32.12 €'}
 *                                    ]
 *                                },
 *                                {
 *                                    sku: 4, label: "L", price: [
 *                                        {vk: '42.12 €'},
 *                                        {vk: '42.12 €'}
 *                                    ]
 *                                }
 *                            ]
 *
 *                        }
 *                    },
 *                    {
 *                        "id": 20,
 *                        "name": "Yessey!",
 *                        "enrichment": {
 *                            variants: [
 *                                {
 *                                    sku: 1, label: "XXS", price: [
 *                                        {vk: '12.12 €'},
 *                                        {vk: '12.12 €'}
 *                                    ]
 *                                },
 *                                {
 *                                    sku: 2, label: "XS", price: [
 *                                        {vk: '22.12 €'},
 *                                        {vk: '22.12 €'}
 *                                    ]
 *                                },
 *                                {
 *                                    sku: 3, label: "S", price: [
 *                                        {vk: '32.12 €'},
 *                                        {vk: '32.12 €'}
 *                                    ]
 *                                },
 *                                {
 *                                    sku: 4, label: "L", price: [
 *                                        {vk: '42.12 €'},
 *                                        {vk: '42.12 €'}
 *                                    ]
 *                                }
 *                            ]
 *
 *                        }
 *                    }
 *                ]
 *            };
 *
 * let callback = function (subject) {
 *                let m = new Map;
 *
 *                for (const [i, b] of Object.entries(subject.data)) {
 *
 *                    let key1 = i;
 *
 *                    for (const [j, c] of Object.entries(b.enrichment.variants)) {
 *                        let key2 = j;
 *
 *                        for (const [k, d] of Object.entries(c.price)) {
 *
 *                            let key3 = k;
 *
 *                            d.name = b.name;
 *                            d.label = c.label;
 *                            d.id = [key1, key2, key3].join('.');
 *
 *                            m.set(d.id, d);
 *                        }
 *
 *                    }
 *                }
 *                return m;
 *            }
 *
 * let map = buildMap(obj, callback, '${name} ${vk}', '${id}')
 *
 * // ↦ Map(3) {
 * //  "0.0.0":"Cassandra 12.12 €",
 * //  "0.0.1":"Cassandra 12.12 €",
 * //  "0.1.0":"Cassandra 22.12 €",
 * //  "0.1.1":"Cassandra 22.12 €",
 * //  "0.2.0":"Cassandra 32.12 €",
 * //  "0.2.1":"Cassandra 32.12 €",
 * //  "0.3.0":"Cassandra 42.12 €",
 * //  "0.3.1":"Cassandra 42.12 €",
 * //  "1.0.0":"Yessey! 12.12 €",
 * //  "1.0.1":"Yessey! 12.12 €",
 * //  "1.1.0":"Yessey! 22.12 €",
 * //  "1.1.1":"Yessey! 22.12 €",
 * //  "1.2.0":"Yessey! 32.12 €",
 * //  "1.2.1":"Yessey! 32.12 €",
 * //  "1.3.0":"Yessey! 42.12 €",
 * //  "1.3.1":"Yessey! 42.12 €"
 * // }
 *
 * @callback Monster.Data~exampleSelectorCallback
 * @param {*} subject subject
 * @return Map
 * @license AGPLv3
 * @since 1.17.0
 * @see {@link Monster.Data.buildMap}
 */

/**
 * @private
 * @param {*} subject
 * @param {string|undefined} definition
 * @param {*} defaultValue
 * @return {*}
 */
export function build(subject, definition, defaultValue) {
	if (definition === undefined) return defaultValue ? defaultValue : subject;
	validateString(definition);

	const regexp = /(?<placeholder>\${(?<path>[a-z\^A-Z.\-_0-9]*)})/gm;
	const array = [...definition.matchAll(regexp)];

	const finder = new Pathfinder(subject);

	if (array.length === 0) {
		return finder.getVia(definition);
	}

	array.forEach((a) => {
		const groups = a?.["groups"];
		const placeholder = groups?.["placeholder"];
		if (placeholder === undefined) return;

		const path = groups?.["path"];

		let v = finder.getVia(path);
		if (v === undefined) v = defaultValue;

		definition = definition.replaceAll(placeholder, v);
	});

	return definition;
}
