# Source: helpers/jsonpath.js

    /**
     * Simplified JSONPath implementation
     *
     * This is designed to make it easier to modify part of a LocusZoom layout, using a syntax based on intent
     *   ("modify association panels") rather than hard-coded assumptions ("modify the first button, and gosh I hope the order doesn't change")
     *
     * This DOES NOT support the full JSONPath specification. Notable limitations:
     * - Arrays can only be indexed by filter expression, not by number (can't ask for "array item 1")
     * - Filter expressions support only exact match, `field === value`. There is no support for "and" statements or
     *  arbitrary JS expressions beyond a single exact comparison. (the parser may be improved in the future if use cases emerge)
     *
     * @module
     * @private
     */

    const ATTR_REGEX = /^(\*|[\w]+)/; // attribute names can be wildcard or valid variable names
    const EXPR_REGEX = /^\[\?\(@((?:\.[\w]+)+) *===? *([0-9.eE-]+|"[^"]*"|'[^']*')\)\]/;  // Arrays can be indexed using filter expressions like `[?(@.id === value)]` where value is a number or a single-or-double quoted string

    function get_next_token(q) {
        // This just grabs everything that looks good.
        // The caller should check that the remaining query is valid.
        if (q.substr(0, 2) === '..') {
            if (q[2] === '[') {
                return {
                    text: '..',
                    attr: '*',
                    depth: '..',
                };
            }
            const m = ATTR_REGEX.exec(q.substr(2));
            if (!m) {
                throw `Cannot parse ${JSON.stringify(q)} as dotdot_attr.`;
            }
            return {
                text: `..${m[0]}`,
                attr: m[1],
                depth: '..',
            };
        } else if (q[0] === '.') {
            const m = ATTR_REGEX.exec(q.substr(1));
            if (!m) {
                throw `Cannot parse ${JSON.stringify(q)} as dot_attr.`;
            }
            return {
                text: `.${m[0]}`,
                attr: m[1],
                depth: '.',
            };
        } else if (q[0] === '[') {
            const m = EXPR_REGEX.exec(q);
            if (!m) {
                throw `Cannot parse ${JSON.stringify(q)} as expr.`;
            }
            let value;
            try {
                // Parse strings and numbers
                value = JSON.parse(m[2]);
            } catch (e) {
                // Handle single-quoted strings
                value = JSON.parse(m[2].replace(/^'|'$/g, '"'));
            }

            return {
                text: m[0],
                attrs: m[1].substr(1).split('.'),
                value,
            };
        } else {
            throw `The query ${JSON.stringify(q)} doesn't look valid.`;
        }
    }

    function normalize_query(q) {
        // Normalize the start of the query so that it's just a bunch of selectors one-after-another.
        // Otherwise the first selector is a little different than the others.
        if (!q) {
            return '';
        }
        if (!['$', '['].includes(q[0])) {
            q = `$.${  q}`;
        } // It starts with a dotless attr, so prepend the implied `$.`.
        if (q[0] === '$') {
            q = q.substr(1);
        }  // strip the leading $
        return q;
    }

    function tokenize (q) {
        q = normalize_query(q);
        let selectors = [];
        while (q.length) {
            const selector = get_next_token(q);
            q = q.substr(selector.text.length);
            selectors.push(selector);
        }
        return selectors;
    }

    /**
     * Fetch the attribute from a dotted path inside a nested object, eg `extract_path({k:['a','b']}, ['k', 1])` would retrieve `'b'`
     *
     * This function returns a three item array `[parent, key, object]`. This is done to support mutating the value, which requires access to the parent.
     *
     * @param obj
     * @param path
     * @returns {Array}
     */
    function get_item_at_deep_path(obj, path) {
        let parent;
        for (let key of path) {
            parent = obj;
            obj = obj[key];
        }
        return [parent, path[path.length - 1], obj];
    }

    function tokens_to_keys(data, selectors) {
        // Resolve the jsonpath query into full path specifier keys in the object, eg
        //  `$..data_layers[?(@.tag === 'association)].color
        //  would become
        // ["panels", 0, "data_layers", 1, "color"]
        if (!selectors.length) {
            return [[]];
        }
        const sel = selectors[0];
        const remaining_selectors = selectors.slice(1);
        let paths = [];

        if (sel.attr && sel.depth === '.' && sel.attr !== '*') { // .attr
            const d = data[sel.attr];
            if (selectors.length === 1) {
                if (d !== undefined) {
                    paths.push([sel.attr]);
                }
            } else {
                paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [sel.attr].concat(p)));
            }
        } else if (sel.attr && sel.depth === '.' && sel.attr === '*') { // .*
            for (let [k, d] of Object.entries(data)) {
                paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [k].concat(p)));
            }
        } else if (sel.attr && sel.depth === '..') { // ..
            // If `sel.attr` matches, recurse with that match.
            // And also recurse on every value using unchanged selectors.
            // I bet `..*..*` duplicates results, so don't do it please.
            if (typeof data === 'object' && data !== null) {
                if (sel.attr !== '*' && sel.attr in data) { // Exact match!
                    paths.push(...tokens_to_keys(data[sel.attr], remaining_selectors).map((p) => [sel.attr].concat(p)));
                }
                for (let [k, d] of Object.entries(data)) {
                    paths.push(...tokens_to_keys(d, selectors).map((p) => [k].concat(p))); // No match, just recurse
                    if (sel.attr === '*') { // Wildcard match
                        paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [k].concat(p)));
                    }
                }
            }
        } else if (sel.attrs) { // [?(@.attr===value)]
            for (let [k, d] of Object.entries(data)) {
                const [_, __, subject] = get_item_at_deep_path(d, sel.attrs);
                if (subject === sel.value) {
                    paths.push(...tokens_to_keys(d, remaining_selectors).map((p) => [k].concat(p)));
                }
            }
        }

        const uniqPaths = uniqBy(paths, JSON.stringify); // dedup
        uniqPaths.sort((a, b) => b.length - a.length || JSON.stringify(a).localeCompare(JSON.stringify(b))); // sort longest-to-shortest, breaking ties lexicographically
        return uniqPaths;
    }

    function uniqBy(arr, key) {
        // Sometimes, the process of resolving paths to selectors returns duplicate results. This returns only the unique paths.
        return [...new Map(arr.map((elem) => [key(elem), elem])).values()];
    }

    function get_items_from_tokens(data, selectors) {
        let items = [];
        for (let path of tokens_to_keys(data, selectors)) {
            items.push(get_item_at_deep_path(data, path));
        }
        return items;
    }

    /**
     * Perform a query, and return the item + its parent context
     * @param data
     * @param query
     * @returns {Array}
     * @private
     */
    function _query(data, query) {
        const tokens = tokenize(query);

        const matches = get_items_from_tokens(data, tokens);
        if (!matches.length) {
            console.warn(`No items matched the specified query: '${query}'`);
        }
        return matches;
    }

    /**
     * Fetch the value(s) for each possible match for a given query. Returns only the item values.
     * @param {object} data The data object to query
     * @param {string} query A JSONPath-compliant query string
     * @returns {Array}
     */
    function query(data, query) {
        return _query(data, query).map((item) => item[2]);
    }

    /**
     * Modify the value(s) for each possible match for a given jsonpath query. Returns the new item values.
     * @param {object} data The data object to query
     * @param {string} query A JSONPath-compliant query string
     * @param {function|*} value_or_callback The new value for the specified field. Mutations will only be applied
     *  after the keys are resolved; this prevents infinite recursion, but could invalidate some matches
     *  (if the mutation removed the expected key).
     */
    function mutate(data, query, value_or_callback) {
        const matches_in_context = _query(data, query);
        return matches_in_context.map(([parent, key, old_value]) => {
            const new_value = (typeof value_or_callback === 'function') ? value_or_callback(old_value) : value_or_callback;
            parent[key] = new_value;
            return new_value;
        });
    }

    export {mutate, query};