/**
 * @author schukai GmbH
 */


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}
 * @memberOf Monster.Data
 */
const PARENT = '^';


/**
 * With the help of the function `buildMap()`, maps can be easily created from data objects.
 *
 * Either a simple definition `a.b.c` or a template `${a.b.c}` can be specified as the path.
 * Key and value can be either a definition or a template. The key does not have to be defined.
 *
 * ```
 * <script type="module">
 * import {buildMap} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/data/buildmap.mjs';
 * console.log(buildMap())
 * </script>
 * ```
 *
 * 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 values of the parent data set, you have to use the `^` character `${id} ${^.name}`.
 *
 * @example
 *
 * import {buildMap} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/source/data/buildmap.mjs';
 * // a typical data structure as reported by an api
 *
 * let map;
 * let obj = {
 *     "data": [
 *         {
 *             "id": 10,
 *             "name": "Cassandra",
 *             "address": {
 *                 "street": "493-4105 Vulputate Street",
 *                 "city": "Saumur",
 *                 "zip": "52628"
 *             }
 *         },
 *         {
 *             "id": 20,
 *             "name": "Holly",
 *             "address": {
 *                 "street": "1762 Eget Rd.",
 *                 "city": "Schwalbach",
 *                 "zip": "952340"
 *             }
 *         },
 *         {
 *             "id": 30,
 *             "name": "Guy",
 *             "address": {
 *                 "street": "957-388 Sollicitudin Avenue",
 *                 "city": "Panchià",
 *                 "zip": "420729"
 *             }
 *         }
 *     ]
 * };
 *
 * // The function is passed this data structure and with the help of the selector `'data.*'` the data to be considered are selected.
 * // The key is given by a simple definition `'id'` and the value is given by a template `'${name} (${address.zip} ${address.city})'`.
 * map = buildMap(obj, 'data.*', '${name} (${address.zip} ${address.city})', 'id');
 * console.log(map);
 *
 * // ↦ Map(3) {
 * //  '10' => 'Cassandra (52628 Saumur)',
 * //  '20' => 'Holly (952340 Schwalbach)',
 * //  '30' => 'Guy (420729 Panchià)'
 * // }
 *
 * // If no key is specified, the key from the selection, here the array index, is taken.
 * map = buildMap(obj, 'data.*', '${name} (${address.zip} ${address.city})');
 * console.log(map);
 *
 * // ↦ Map(3) {
 * //  '0' => 'Cassandra (52628 Saumur)',
 * //  '1' => 'Holly (952340 Schwalbach)',
 * //  '2' => 'Guy (420729 Panchià)'
 * // }
 *
 * // a filter (function(value, key) {}) can be specified to accept only defined entries.
 * map = buildMap(obj, 'data.*', '${name} (${address.zip} ${address.city})', 'id', function (value, key) {
 *                return (value['id'] >= 20) ? true : false
 *            });
 * console.log(map);
 *
 * // ↦ Map(2) {
 * //  20 => 'Holly (952340 Schwalbach)',
 * //  30 => 'Guy (420729 Panchià)'
 * // }
 *
 * @param {*} subject
 * @param {string|Monster.Data~exampleSelectorCallback} selector
 * @param {string} [valueTemplate]
 * @param {string} [keyTemplate]
 * @param {Monster.Data~exampleFilterCallback} [filter]
 * @return {*}
 * @memberOf Monster.Data
 * @throws {TypeError} value is neither a string nor a function
 * @throws {TypeError} the selector callback must 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);
    });

}


/**
 * @private
 * @param {*} subject
 * @param {string|Monster.Data~exampleSelectorCallback} selector
 * @param {Monster.Data~exampleFilterCallback} [filter]
 * @param {function} callback
 * @return {Map}
 * @throws {TypeError} 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 result = this;
    const currentMap = new Map;

    const resultLength = result.size;

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

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

        current = parts.shift();
        currentPath.push(current);

        if (current === WILDCARD) {

            let finder = new Pathfinder(subject);
            let map;

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

            for (const [k, o] of map) {

                let copyKey = clone(key);

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

                let kk = copyKey.join(DELIMITER);
                let sub = buildFlatMap.call(result, 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 === result.size) {
        for (const [k, o] of currentMap) {
            result.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
 * @memberOf Monster.Data
 * @see {@link Monster.Data.buildMap}
 */

/**
 * Alternatively to a string selector a callback can be specified. this must return a map.
 *
 * @example
 * import {buildMap} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@latest/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
 * @since 1.17.0
 * @memberOf Monster.Data
 * @see {@link Monster.Data.buildMap}
 */

/**
 * @private
 * @param {*} subject
 * @param {string|undefined} definition
 * @param {*} defaultValue
 * @return {*}
 */
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)];

    let finder = new Pathfinder(subject);

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

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

        let path = groups?.['path']

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

        definition = definition.replaceAll(placeholder, v);


    })

    return definition;

}