/**
 * @fileoverview The JSONSourceCode class.
 * @author Nicholas C. Zakas
 */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { iterator } from "@humanwhocodes/momoa";
import { VisitNodeStep, TextSourceCodeBase, ConfigCommentParser, Directive, } from "@eslint/plugin-kit";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
 * @import { DocumentNode, AnyNode, Token, LocationRange } from "@humanwhocodes/momoa";
 * @import { FileProblem, DirectiveType, RulesConfig } from "@eslint/core";
 * @import { JSONSyntaxElement } from "../types.js";
 * @import { JSONLanguageOptions } from "./json-language.js";
 */
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const commentParser = new ConfigCommentParser();
const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u;
/**
 * A class to represent a step in the traversal process.
 */
class JSONTraversalStep extends VisitNodeStep {
    /**
     * The target of the step.
     * @type {AnyNode}
     */
    target = undefined;
    /**
     * Creates a new instance.
     * @param {Object} options The options for the step.
     * @param {AnyNode} options.target The target of the step.
     * @param {1|2} options.phase The phase of the step.
     * @param {Array<any>} options.args The arguments of the step.
     */
    constructor({ target, phase, args }) {
        super({ target, phase, args });
        this.target = target;
    }
}
/**
 * Processes tokens to extract comments and their starting tokens.
 * @param {Array<Token>} tokens The tokens to process.
 * @returns {{ comments: Array<Token>, starts: Map<number, number>, ends: Map<number, number>}}
 * An object containing an array of comments, a map of starting token range to token index, and
 * a map of ending token range to token index.
 */
function processTokens(tokens) {
    /** @type {Array<Token>} */
    const comments = [];
    /** @type {Map<number, number>} */
    const starts = new Map();
    /** @type {Map<number, number>} */
    const ends = new Map();
    for (let i = 0; i < tokens.length; i++) {
        const token = tokens[i];
        if (token.type.endsWith("Comment")) {
            comments.push(token);
        }
        starts.set(token.range[0], i);
        ends.set(token.range[1], i);
    }
    return { comments, starts, ends };
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
 * JSON Source Code Object
 * @extends {TextSourceCodeBase<{LangOptions: JSONLanguageOptions, RootNode: DocumentNode, SyntaxElementWithLoc: JSONSyntaxElement, ConfigNode: Token}>}
 */
export class JSONSourceCode extends TextSourceCodeBase {
    /**
     * Cached traversal steps.
     * @type {Array<JSONTraversalStep>|undefined}
     */
    #steps;
    /**
     * Cache of parent nodes.
     * @type {WeakMap<AnyNode, AnyNode>}
     */
    #parents = new WeakMap();
    /**
     * Collection of inline configuration comments.
     * @type {Array<Token>}
     */
    #inlineConfigComments;
    /**
     * The AST of the source code.
     * @type {DocumentNode}
     */
    ast = undefined;
    /**
     * The comment tokens in the source code.
     * @type {Array<Token>|undefined}
     */
    comments;
    /**
     * A map of token start positions to their corresponding index.
     * @type {Map<number, number>}
     */
    #tokenStarts;
    /**
     * A map of token end positions to their corresponding index.
     * @type {Map<number, number>}
     */
    #tokenEnds;
    /**
     * Creates a new instance.
     * @param {Object} options The options for the instance.
     * @param {string} options.text The source code text.
     * @param {DocumentNode} options.ast The root AST node.
     */
    constructor({ text, ast }) {
        super({ text, ast, lineEndingPattern: /\r\n|[\r\n]/u });
        this.ast = ast;
        const { comments, starts, ends } = processTokens(this.ast.tokens ?? []);
        this.comments = comments;
        this.#tokenStarts = starts;
        this.#tokenEnds = ends;
    }
    /**
     * Returns the value of the given comment.
     * @param {Token} comment The comment to get the value of.
     * @returns {string} The value of the comment.
     * @throws {Error} When an unexpected comment type is passed.
     */
    #getCommentValue(comment) {
        if (comment.type === "LineComment") {
            return this.getText(comment).slice(2); // strip leading `//`
        }
        if (comment.type === "BlockComment") {
            return this.getText(comment).slice(2, -2); // strip leading `/*` and trailing `*/`
        }
        throw new Error(`Unexpected comment type '${comment.type}'`);
    }
    /**
     * Returns an array of all inline configuration nodes found in the
     * source code.
     * @returns {Array<Token>} An array of all inline configuration nodes.
     */
    getInlineConfigNodes() {
        if (!this.#inlineConfigComments) {
            this.#inlineConfigComments = this.comments.filter(comment => INLINE_CONFIG.test(this.#getCommentValue(comment)));
        }
        return this.#inlineConfigComments ?? [];
    }
    /**
     * Returns directives that enable or disable rules along with any problems
     * encountered while parsing the directives.
     * @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
     *      that ESLint needs to further process the directives.
     */
    getDisableDirectives() {
        /** @type {Array<FileProblem>} */
        const problems = [];
        /** @type {Array<Directive>} */
        const directives = [];
        this.getInlineConfigNodes().forEach(comment => {
            const { label, value, justification } = commentParser.parseDirective(this.#getCommentValue(comment));
            // `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
            if (label === "eslint-disable-line" &&
                comment.loc.start.line !== comment.loc.end.line) {
                const message = `${label} comment should not span multiple lines.`;
                problems.push({
                    ruleId: null,
                    message,
                    loc: comment.loc,
                });
                return;
            }
            switch (label) {
                case "eslint-disable":
                case "eslint-enable":
                case "eslint-disable-next-line":
                case "eslint-disable-line": {
                    const directiveType = label.slice("eslint-".length);
                    directives.push(new Directive({
                        type: /** @type {DirectiveType} */ (directiveType),
                        node: comment,
                        value,
                        justification,
                    }));
                }
                // no default
            }
        });
        return { problems, directives };
    }
    /**
     * Returns inline rule configurations along with any problems
     * encountered while parsing the configurations.
     * @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:LocationRange}>}} Information
     *      that ESLint needs to further process the rule configurations.
     */
    applyInlineConfig() {
        /** @type {Array<FileProblem>} */
        const problems = [];
        /** @type {Array<{config:{rules:RulesConfig},loc:LocationRange}>} */
        const configs = [];
        this.getInlineConfigNodes().forEach(comment => {
            const { label, value } = commentParser.parseDirective(this.#getCommentValue(comment));
            if (label === "eslint") {
                const parseResult = commentParser.parseJSONLikeConfig(value);
                if (parseResult.ok) {
                    configs.push({
                        config: {
                            rules: parseResult.config,
                        },
                        loc: comment.loc,
                    });
                }
                else {
                    problems.push({
                        ruleId: null,
                        message: 
                        /** @type {{ok: false, error: { message: string }}} */ (parseResult).error.message,
                        loc: comment.loc,
                    });
                }
            }
        });
        return {
            configs,
            problems,
        };
    }
    /**
     * Returns the parent of the given node.
     * @param {AnyNode} node The node to get the parent of.
     * @returns {AnyNode|undefined} The parent of the node.
     */
    getParent(node) {
        return this.#parents.get(node);
    }
    /**
     * Traverse the source code and return the steps that were taken.
     * @returns {Iterable<JSONTraversalStep>} The steps that were taken while traversing the source code.
     */
    traverse() {
        // Because the AST doesn't mutate, we can cache the steps
        if (this.#steps) {
            return this.#steps.values();
        }
        /** @type {Array<JSONTraversalStep>} */
        const steps = (this.#steps = []);
        for (const { node, parent, phase } of iterator(this.ast)) {
            if (parent) {
                this.#parents.set(
                /** @type {AnyNode} */ (node), 
                /** @type {AnyNode} */ (parent));
            }
            steps.push(new JSONTraversalStep({
                target: /** @type {AnyNode} */ (node),
                phase: phase === "enter" ? 1 : 2,
                args: [node, parent],
            }));
        }
        return steps;
    }
    /**
     * Gets the token before the given node or token, optionally including comments.
     * @param {AnyNode|Token} nodeOrToken The node or token to get the previous token for.
     * @param {Object} [options] Options object.
     * @param {boolean} [options.includeComments] If true, return comments when they are present.
     * @returns {Token|null} The previous token or comment, or null if there is none.
     */
    getTokenBefore(nodeOrToken, { includeComments = false } = {}) {
        const index = this.#tokenStarts.get(nodeOrToken.range[0]);
        if (index === undefined) {
            return null;
        }
        let previousIndex = index - 1;
        if (previousIndex < 0) {
            return null;
        }
        const tokens = this.ast.tokens;
        let tokenOrComment = tokens[previousIndex];
        if (includeComments) {
            return tokenOrComment;
        }
        // skip comments
        while (tokenOrComment?.type.endsWith("Comment")) {
            previousIndex--;
            if (previousIndex < 0) {
                return null;
            }
            tokenOrComment = tokens[previousIndex];
        }
        return tokenOrComment;
    }
    /**
     * Gets the token after the given node or token, skipping any comments unless includeComments is true.
     * @param {AnyNode|Token} nodeOrToken The node or token to get the next token for.
     * @param {Object} [options] Options object.
     * @param {boolean} [options.includeComments=false] If true, return comments when they are present.
     * @returns {Token|null} The next token or comment, or null if there is none.
     */
    getTokenAfter(nodeOrToken, { includeComments = false } = {}) {
        const index = this.#tokenEnds.get(nodeOrToken.range[1]);
        if (index === undefined) {
            return null;
        }
        let nextIndex = index + 1;
        const tokens = this.ast.tokens;
        if (nextIndex >= tokens.length) {
            return null;
        }
        let tokenOrComment = tokens[nextIndex];
        if (includeComments) {
            return tokenOrComment;
        }
        // skip comments
        while (tokenOrComment?.type.endsWith("Comment")) {
            nextIndex++;
            if (nextIndex >= tokens.length) {
                return null;
            }
            tokenOrComment = tokens[nextIndex];
        }
        return tokenOrComment;
    }
}
