"use strict";

const evk = require("eslint-visitor-keys");
const recast = require("recast");
const espree = require("espree");
const assert = require("assert");

/**
 * Determines whether an AST node could be an expression, based on the type
 * @param {ASTNode} node The node
 * @returns {boolean} `true` if the node could be an expression
 */
function isMaybeExpression(node) {
    return node.type.endsWith("Expression") ||
        node.type === "Identifier" ||
        node.type === "MetaProperty" ||
        node.type.endsWith("Literal");
}

/**
 * Determines whether an AST node is a statement
 * @param {ASTNode} node The node
 * @returns {boolean} `true` if the node is a statement
 */
function isStatement(node) {
    return node.type.endsWith("Statement") || node.type.endsWith("Declaration");
}

/**
 * Given "bad" source text (e.g. an code sample that causes a rule to crash), tries to return a smaller
 * piece of source text which is also "bad", to make it easier for a human to figure out where the
 * problem is.
 * @param {Object} options Options to process
 * @param {string} options.sourceText Initial piece of "bad" source text
 * @param {function(string): boolean} options.predicate A predicate that returns `true` for bad source text and `false` for good source text
 * @param {Parser} [options.parser] The parser used to parse the source text. Defaults to a modified
 * version of espree that uses recent parser options.
 * @param {Object} [options.visitorKeys] The visitor keys of the AST. Defaults to eslint-visitor-keys.
 * @returns {string} Another piece of "bad" source text, which may or may not be smaller than the original source text.
 */
function reduceBadExampleSize({
    sourceText,
    predicate,
    parser = {
        parse: (code, options) =>
            espree.parse(code, {
                ...options,
                loc: true,
                range: true,
                raw: true,
                tokens: true,
                comment: true,
                eslintVisitorKeys: true,
                eslintScopeManager: true,
                ecmaVersion: espree.latestEcmaVersion,
                sourceType: "script"
            })
    },
    visitorKeys = evk.KEYS
}) {
    let counter = 0;

    /**
     * Returns a new unique identifier
     * @returns {string} A name for a new identifier
     */
    function generateNewIdentifierName() {
        return `$${(counter++)}`;
    }

    /**
     * Determines whether a source text sample is "bad"
     * @param {string} updatedSourceText The sample
     * @returns {boolean} `true` if the sample is "bad"
     */
    function reproducesBadCase(updatedSourceText) {
        try {
            parser.parse(updatedSourceText);
        } catch {
            return false;
        }

        return predicate(updatedSourceText);
    }

    assert(reproducesBadCase(sourceText), "Original source text should reproduce issue");
    const parseResult = recast.parse(sourceText, { parser });

    /**
     * Recursively removes descendant subtrees of the given AST node and replaces
     * them with simplified variants to produce a simplified AST which is still considered "bad".
     * @param {ASTNode} node An AST node to prune. May be mutated by this call, but the
     * resulting AST will still produce "bad" source code.
     * @returns {void}
     */
    function pruneIrrelevantSubtrees(node) {
        for (const key of visitorKeys[node.type]) {
            if (Array.isArray(node[key])) {
                for (let index = node[key].length - 1; index >= 0; index--) {
                    const [childNode] = node[key].splice(index, 1);

                    if (!reproducesBadCase(recast.print(parseResult).code)) {
                        node[key].splice(index, 0, childNode);
                        if (childNode) {
                            pruneIrrelevantSubtrees(childNode);
                        }
                    }
                }
            } else if (typeof node[key] === "object" && node[key] !== null) {

                const childNode = node[key];

                if (isMaybeExpression(childNode)) {
                    node[key] = { type: "Identifier", name: generateNewIdentifierName(), range: childNode.range };
                    if (!reproducesBadCase(recast.print(parseResult).code)) {
                        node[key] = childNode;
                        pruneIrrelevantSubtrees(childNode);
                    }
                } else if (isStatement(childNode)) {
                    node[key] = { type: "EmptyStatement", range: childNode.range };
                    if (!reproducesBadCase(recast.print(parseResult).code)) {
                        node[key] = childNode;
                        pruneIrrelevantSubtrees(childNode);
                    }
                }
            }
        }
    }

    /**
     * Recursively tries to extract a descendant node from the AST that is "bad" on its own
     * @param {ASTNode} node A node which produces "bad" source code
     * @returns {ASTNode} A descendent of `node` which is also bad
     */
    function extractRelevantChild(node) {
        const childNodes = [].concat(
            ...visitorKeys[node.type]
                .map(key => (Array.isArray(node[key]) ? node[key] : [node[key]]))
        );

        for (const childNode of childNodes) {
            if (!childNode) {
                continue;
            }

            if (isMaybeExpression(childNode)) {
                if (reproducesBadCase(recast.print(childNode).code)) {
                    return extractRelevantChild(childNode);
                }

            } else if (isStatement(childNode)) {
                if (reproducesBadCase(recast.print(childNode).code)) {
                    return extractRelevantChild(childNode);
                }
            } else {
                const childResult = extractRelevantChild(childNode);

                if (reproducesBadCase(recast.print(childResult).code)) {
                    return childResult;
                }
            }
        }
        return node;
    }

    /**
     * Removes and simplifies comments from the source text
     * @param {string} text A piece of "bad" source text
     * @returns {string} A piece of "bad" source text with fewer and/or simpler comments.
     */
    function removeIrrelevantComments(text) {
        const ast = parser.parse(text);

        if (ast.comments) {
            for (const comment of ast.comments) {
                for (const potentialSimplification of [

                    // Try deleting the comment
                    `${text.slice(0, comment.range[0])}${text.slice(comment.range[1])}`,

                    // Try replacing the comment with a space
                    `${text.slice(0, comment.range[0])} ${text.slice(comment.range[1])}`,

                    // Try deleting the contents of the comment
                    text.slice(0, comment.range[0] + 2) + text.slice(comment.type === "Block" ? comment.range[1] - 2 : comment.range[1])
                ]) {
                    if (reproducesBadCase(potentialSimplification)) {
                        return removeIrrelevantComments(potentialSimplification);
                    }
                }
            }
        }

        return text;
    }

    pruneIrrelevantSubtrees(parseResult.program);
    const relevantChild = recast.print(extractRelevantChild(parseResult.program)).code;

    assert(reproducesBadCase(relevantChild), "Extracted relevant source text should reproduce issue");
    const result = removeIrrelevantComments(relevantChild);

    assert(reproducesBadCase(result), "Source text with irrelevant comments removed should reproduce issue");
    return result;
}

module.exports = reduceBadExampleSize;
