/**
 * @fileoverview Validates JSDoc comments are syntactically correct
 * @author Nicholas C. Zakas
 */
"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const doctrine = require("doctrine");

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

module.exports = {
    meta: {
        docs: {
            description: "enforce valid JSDoc comments",
            category: "Possible Errors",
            recommended: false,
            url: "https://eslint.org/docs/rules/valid-jsdoc"
        },

        schema: [
            {
                type: "object",
                properties: {
                    prefer: {
                        type: "object",
                        additionalProperties: {
                            type: "string"
                        }
                    },
                    preferType: {
                        type: "object",
                        additionalProperties: {
                            type: "string"
                        }
                    },
                    requireReturn: {
                        type: "boolean"
                    },
                    requireParamDescription: {
                        type: "boolean"
                    },
                    requireReturnDescription: {
                        type: "boolean"
                    },
                    matchDescription: {
                        type: "string"
                    },
                    requireReturnType: {
                        type: "boolean"
                    },
                    requireParamType: {
                        type: "boolean"
                    }
                },
                additionalProperties: false
            }
        ],

        fixable: "code"
    },

    create(context) {

        const options = context.options[0] || {},
            prefer = options.prefer || {},
            sourceCode = context.getSourceCode(),

            // these both default to true, so you have to explicitly make them false
            requireReturn = options.requireReturn !== false,
            requireParamDescription = options.requireParamDescription !== false,
            requireReturnDescription = options.requireReturnDescription !== false,
            requireReturnType = options.requireReturnType !== false,
            requireParamType = options.requireParamType !== false,
            preferType = options.preferType || {},
            checkPreferType = Object.keys(preferType).length !== 0;

        //--------------------------------------------------------------------------
        // Helpers
        //--------------------------------------------------------------------------

        // Using a stack to store if a function returns or not (handling nested functions)
        const fns = [];

        /**
         * Check if node type is a Class
         * @param {ASTNode} node node to check.
         * @returns {boolean} True is its a class
         * @private
         */
        function isTypeClass(node) {
            return node.type === "ClassExpression" || node.type === "ClassDeclaration";
        }

        /**
         * When parsing a new function, store it in our function stack.
         * @param {ASTNode} node A function node to check.
         * @returns {void}
         * @private
         */
        function startFunction(node) {
            fns.push({
                returnPresent: (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") ||
                    isTypeClass(node) || node.async
            });
        }

        /**
         * Indicate that return has been found in the current function.
         * @param {ASTNode} node The return node.
         * @returns {void}
         * @private
         */
        function addReturn(node) {
            const functionState = fns[fns.length - 1];

            if (functionState && node.argument !== null) {
                functionState.returnPresent = true;
            }
        }

        /**
         * Check if return tag type is void or undefined
         * @param {Object} tag JSDoc tag
         * @returns {boolean} True if its of type void or undefined
         * @private
         */
        function isValidReturnType(tag) {
            return tag.type === null || tag.type.name === "void" || tag.type.type === "UndefinedLiteral";
        }

        /**
         * Check if type should be validated based on some exceptions
         * @param {Object} type JSDoc tag
         * @returns {boolean} True if it can be validated
         * @private
         */
        function canTypeBeValidated(type) {
            return type !== "UndefinedLiteral" && // {undefined} as there is no name property available.
                   type !== "NullLiteral" && // {null}
                   type !== "NullableLiteral" && // {?}
                   type !== "FunctionType" && // {function(a)}
                   type !== "AllLiteral"; // {*}
        }

        /**
         * Extract the current and expected type based on the input type object
         * @param {Object} type JSDoc tag
         * @returns {{currentType: Doctrine.Type, expectedTypeName: string}} The current type annotation and
         * the expected name of the annotation
         * @private
         */
        function getCurrentExpectedTypes(type) {
            let currentType;

            if (type.name) {
                currentType = type;
            } else if (type.expression) {
                currentType = type.expression;
            }

            return {
                currentType,
                expectedTypeName: currentType && preferType[currentType.name]
            };
        }

        /**
         * Gets the location of a JSDoc node in a file
         * @param {Token} jsdocComment The comment that this node is parsed from
         * @param {{range: number[]}} parsedJsdocNode A tag or other node which was parsed from this comment
         * @returns {{start: SourceLocation, end: SourceLocation}} The 0-based source location for the tag
         */
        function getAbsoluteRange(jsdocComment, parsedJsdocNode) {
            return {
                start: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[0]),
                end: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[1])
            };
        }

        /**
         * Validate type for a given JSDoc node
         * @param {Object} jsdocNode JSDoc node
         * @param {Object} type JSDoc tag
         * @returns {void}
         * @private
         */
        function validateType(jsdocNode, type) {
            if (!type || !canTypeBeValidated(type.type)) {
                return;
            }

            const typesToCheck = [];
            let elements = [];

            switch (type.type) {
                case "TypeApplication": // {Array.<String>}
                    elements = type.applications[0].type === "UnionType" ? type.applications[0].elements : type.applications;
                    typesToCheck.push(getCurrentExpectedTypes(type));
                    break;
                case "RecordType": // {{20:String}}
                    elements = type.fields;
                    break;
                case "UnionType": // {String|number|Test}
                case "ArrayType": // {[String, number, Test]}
                    elements = type.elements;
                    break;
                case "FieldType": // Array.<{count: number, votes: number}>
                    if (type.value) {
                        typesToCheck.push(getCurrentExpectedTypes(type.value));
                    }
                    break;
                default:
                    typesToCheck.push(getCurrentExpectedTypes(type));
            }

            elements.forEach(validateType.bind(null, jsdocNode));

            typesToCheck.forEach(typeToCheck => {
                if (typeToCheck.expectedTypeName &&
                    typeToCheck.expectedTypeName !== typeToCheck.currentType.name) {
                    context.report({
                        node: jsdocNode,
                        message: "Use '{{expectedTypeName}}' instead of '{{currentTypeName}}'.",
                        loc: getAbsoluteRange(jsdocNode, typeToCheck.currentType),
                        data: {
                            currentTypeName: typeToCheck.currentType.name,
                            expectedTypeName: typeToCheck.expectedTypeName
                        },
                        fix(fixer) {
                            return fixer.replaceTextRange(
                                typeToCheck.currentType.range.map(indexInComment => jsdocNode.range[0] + 2 + indexInComment),
                                typeToCheck.expectedTypeName
                            );
                        }
                    });
                }
            });
        }

        /**
         * Validate the JSDoc node and output warnings if anything is wrong.
         * @param {ASTNode} node The AST node to check.
         * @returns {void}
         * @private
         */
        function checkJSDoc(node) {
            const jsdocNode = sourceCode.getJSDocComment(node),
                functionData = fns.pop(),
                paramTagsByName = Object.create(null),
                paramTags = [];
            let hasReturns = false,
                returnsTag,
                hasConstructor = false,
                isInterface = false,
                isOverride = false,
                isAbstract = false;

            // make sure only to validate JSDoc comments
            if (jsdocNode) {
                let jsdoc;

                try {
                    jsdoc = doctrine.parse(jsdocNode.value, {
                        strict: true,
                        unwrap: true,
                        sloppy: true,
                        range: true
                    });
                } catch (ex) {

                    if (/braces/i.test(ex.message)) {
                        context.report({ node: jsdocNode, message: "JSDoc type missing brace." });
                    } else {
                        context.report({ node: jsdocNode, message: "JSDoc syntax error." });
                    }

                    return;
                }

                jsdoc.tags.forEach(tag => {

                    switch (tag.title.toLowerCase()) {

                        case "param":
                        case "arg":
                        case "argument":
                            paramTags.push(tag);
                            break;

                        case "return":
                        case "returns":
                            hasReturns = true;
                            returnsTag = tag;
                            break;

                        case "constructor":
                        case "class":
                            hasConstructor = true;
                            break;

                        case "override":
                        case "inheritdoc":
                            isOverride = true;
                            break;

                        case "abstract":
                        case "virtual":
                            isAbstract = true;
                            break;

                        case "interface":
                            isInterface = true;
                            break;

                        // no default
                    }

                    // check tag preferences
                    if (Object.prototype.hasOwnProperty.call(prefer, tag.title) && tag.title !== prefer[tag.title]) {
                        const entireTagRange = getAbsoluteRange(jsdocNode, tag);

                        context.report({
                            node: jsdocNode,
                            message: "Use @{{name}} instead.",
                            loc: {
                                start: entireTagRange.start,
                                end: {
                                    line: entireTagRange.start.line,
                                    column: entireTagRange.start.column + `@${tag.title}`.length
                                }
                            },
                            data: { name: prefer[tag.title] },
                            fix(fixer) {
                                return fixer.replaceTextRange(
                                    [
                                        jsdocNode.range[0] + tag.range[0] + 3,
                                        jsdocNode.range[0] + tag.range[0] + tag.title.length + 3
                                    ],
                                    prefer[tag.title]
                                );
                            }
                        });
                    }

                    // validate the types
                    if (checkPreferType && tag.type) {
                        validateType(jsdocNode, tag.type);
                    }
                });

                paramTags.forEach(param => {
                    if (requireParamType && !param.type) {
                        context.report({
                            node: jsdocNode,
                            message: "Missing JSDoc parameter type for '{{name}}'.",
                            loc: getAbsoluteRange(jsdocNode, param),
                            data: { name: param.name }
                        });
                    }
                    if (!param.description && requireParamDescription) {
                        context.report({
                            node: jsdocNode,
                            message: "Missing JSDoc parameter description for '{{name}}'.",
                            loc: getAbsoluteRange(jsdocNode, param),
                            data: { name: param.name }
                        });
                    }
                    if (paramTagsByName[param.name]) {
                        context.report({
                            node: jsdocNode,
                            message: "Duplicate JSDoc parameter '{{name}}'.",
                            loc: getAbsoluteRange(jsdocNode, param),
                            data: { name: param.name }
                        });
                    } else if (param.name.indexOf(".") === -1) {
                        paramTagsByName[param.name] = param;
                    }
                });

                if (hasReturns) {
                    if (!requireReturn && !functionData.returnPresent && (returnsTag.type === null || !isValidReturnType(returnsTag)) && !isAbstract) {
                        context.report({
                            node: jsdocNode,
                            message: "Unexpected @{{title}} tag; function has no return statement.",
                            loc: getAbsoluteRange(jsdocNode, returnsTag),
                            data: {
                                title: returnsTag.title
                            }
                        });
                    } else {
                        if (requireReturnType && !returnsTag.type) {
                            context.report({ node: jsdocNode, message: "Missing JSDoc return type." });
                        }

                        if (!isValidReturnType(returnsTag) && !returnsTag.description && requireReturnDescription) {
                            context.report({ node: jsdocNode, message: "Missing JSDoc return description." });
                        }
                    }
                }

                // check for functions missing @returns
                if (!isOverride && !hasReturns && !hasConstructor && !isInterface &&
                    node.parent.kind !== "get" && node.parent.kind !== "constructor" &&
                    node.parent.kind !== "set" && !isTypeClass(node)) {
                    if (requireReturn || (functionData.returnPresent && !node.async)) {
                        context.report({
                            node: jsdocNode,
                            message: "Missing JSDoc @{{returns}} for function.",
                            data: {
                                returns: prefer.returns || "returns"
                            }
                        });
                    }
                }

                // check the parameters
                const jsdocParamNames = Object.keys(paramTagsByName);

                if (node.params) {
                    node.params.forEach((param, paramsIndex) => {
                        const bindingParam = param.type === "AssignmentPattern"
                            ? param.left
                            : param;

                        // TODO(nzakas): Figure out logical things to do with destructured, default, rest params
                        if (bindingParam.type === "Identifier") {
                            const name = bindingParam.name;

                            if (jsdocParamNames[paramsIndex] && (name !== jsdocParamNames[paramsIndex])) {
                                context.report({
                                    node: jsdocNode,
                                    message: "Expected JSDoc for '{{name}}' but found '{{jsdocName}}'.",
                                    loc: getAbsoluteRange(jsdocNode, paramTagsByName[jsdocParamNames[paramsIndex]]),
                                    data: {
                                        name,
                                        jsdocName: jsdocParamNames[paramsIndex]
                                    }
                                });
                            } else if (!paramTagsByName[name] && !isOverride) {
                                context.report({
                                    node: jsdocNode,
                                    message: "Missing JSDoc for parameter '{{name}}'.",
                                    data: {
                                        name
                                    }
                                });
                            }
                        }
                    });
                }

                if (options.matchDescription) {
                    const regex = new RegExp(options.matchDescription);

                    if (!regex.test(jsdoc.description)) {
                        context.report({ node: jsdocNode, message: "JSDoc description does not satisfy the regex pattern." });
                    }
                }

            }

        }

        //--------------------------------------------------------------------------
        // Public
        //--------------------------------------------------------------------------

        return {
            ArrowFunctionExpression: startFunction,
            FunctionExpression: startFunction,
            FunctionDeclaration: startFunction,
            ClassExpression: startFunction,
            ClassDeclaration: startFunction,
            "ArrowFunctionExpression:exit": checkJSDoc,
            "FunctionExpression:exit": checkJSDoc,
            "FunctionDeclaration:exit": checkJSDoc,
            "ClassExpression:exit": checkJSDoc,
            "ClassDeclaration:exit": checkJSDoc,
            ReturnStatement: addReturn
        };

    }
};
