/**
 * @fileoverview Rule to disallow deprecated API.
 * @author Toru Nagashima
 * @copyright 2016 Toru Nagashima. All rights reserved.
 * See LICENSE file in root directory for full license.
 */
"use strict"

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

const deprecatedApis = require("../util/deprecated-apis")
const getDocsUrl = require("../util/get-docs-url")
const getValueIfString = require("../util/get-value-if-string")

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

const SENTINEL_TYPE = /^(?:.+?Statement|.+?Declaration|(?:Array|ArrowFunction|Assignment|Call|Class|Function|Member|New|Object)Expression|AssignmentPattern|Program|VariableDeclarator)$/
const MODULE_ITEMS = getDeprecatedItems(deprecatedApis.modules, [], [])
const GLOBAL_ITEMS = getDeprecatedItems(deprecatedApis.globals, [], [])

/**
 * Gets the array of deprecated items.
 *
 * It's the paths which are separated by dots.
 * E.g. `buffer.Buffer`, `events.EventEmitter.listenerCount`
 *
 * @param {object} definition - The definition of deprecated APIs.
 * @param {string[]} result - The array of the result.
 * @param {string[]} stack - The array to manage the stack of paths.
 * @returns {string[]} `result`.
 */
function getDeprecatedItems(definition, result, stack) {
    for (const key of Object.keys(definition)) {
        const item = definition[key]

        if (key === "$call") {
            result.push(`${stack.join(".")}()`)
        }
        else if (key === "$constructor") {
            result.push(`new ${stack.join(".")}()`)
        }
        else {
            stack.push(key)

            if (item.$deprecated) {
                result.push(stack.join("."))
            }
            else {
                getDeprecatedItems(item, result, stack)
            }

            stack.pop()
        }
    }

    return result
}

/**
 * Converts from a version number to a version text to display.
 *
 * @param {number} value - A version number to convert.
 * @returns {string} Covnerted text.
 */
function toVersionText(value) {
    if (value <= 0.12) {
        return value.toFixed(2)
    }
    if (value < 1) {
        return value.toFixed(1)
    }
    return String(value)
}

/**
 * Makes a replacement message.
 *
 * @param {string|null} replacedBy - The text of substitute way.
 * @returns {string} Replacement message.
 */
function toReplaceMessage(replacedBy) {
    return replacedBy ? ` Use ${replacedBy} instead.` : ""
}

/**
 * Gets the property name from a MemberExpression node or a Property node.
 *
 * @param {ASTNode} node - A node to get.
 * @returns {string|null} The property name of the node.
 */
function getPropertyName(node) {
    switch (node.type) {
        case "MemberExpression":
            if (node.computed) {
                return getValueIfString(node.property)
            }
            return node.property.name

        case "Property":
            if (node.computed) {
                return getValueIfString(node.key)
            }
            if (node.key.type === "Literal") {
                return String(node.key.value)
            }
            return node.key.name

        // no default
    }

    /* istanbul ignore next: unreachable */
    return null
}

/**
 * Checks a given node is a ImportDeclaration node.
 *
 * @param {ASTNode} node - A node to check.
 * @returns {boolean} `true` if the node is a ImportDeclaration node.
 */
function isImportDeclaration(node) {
    return node.type === "ImportDeclaration"
}

/**
 * Finds the variable object of a given Identifier node.
 *
 * @param {ASTNode} node - An Identifier node to find.
 * @param {escope.Scope} initialScope - A scope to start searching.
 * @returns {escope.Variable} Found variable object.
 */
function findVariable(node, initialScope) {
    const location = node.range[0]
    let variable = null

    // Dive into the scope that the node exists.
    for (const childScope of initialScope.childScopes) {
        const range = childScope.block.range

        if (range[0] <= location && location < range[1]) {
            variable = findVariable(node, childScope)
            if (variable != null) {
                return variable
            }
        }
    }

    // Find the variable of that name in this scope or ancestor scopes.
    let scope = initialScope
    while (scope != null) {
        variable = scope.set.get(node.name)
        if (variable != null) {
            return variable
        }

        scope = scope.upper
    }

    return null
}

/**
 * Gets the top member expression node.
 *
 * @param {ASTNode} identifier - The node to get.
 * @returns {ASTNode} The top member expression node.
 */
function getTopMemberExpression(identifier) {
    if (identifier.type !== "Identifier" && identifier.type !== "Literal") {
        return identifier
    }

    let node = identifier
    while (node.parent.type === "MemberExpression") {
        node = node.parent
    }

    return node
}

/**
 * The definition of this rule.
 *
 * @param {RuleContext} context - The rule context to check.
 * @returns {object} The definition of this rule.
 */
function create(context) {
    const options = context.options[0] || {}
    const ignoredModuleItems = options.ignoreModuleItems || []
    const ignoredGlobalItems = options.ignoreGlobalItems || []
    let globalScope = null
    const varStack = []

    /**
     * Reports a use of a deprecated API.
     *
     * @param {ASTNode} node - A node to report.
     * @param {string} name - The name of a deprecated API.
     * @param {{since: number, replacedBy: string}} info - Information of the API.
     * @returns {void}
     */
    function report(node, name, info) {
        context.report({
            node,
            loc: getTopMemberExpression(node).loc,
            message: "{{name}} was deprecated since v{{version}}.{{replace}}",
            data: {
                name,
                version: toVersionText(info.since),
                replace: toReplaceMessage(info.replacedBy),
            },
        })
    }

    /**
     * Reports a use of a deprecated module.
     *
     * @param {ASTNode} node - A node to report.
     * @param {string} name - The name of a deprecated module.
     * @param {{since: number, replacedBy: string, global: boolean}} info - Information of the module.
     * @returns {void}
     */
    function reportModule(node, name, info) {
        if (ignoredModuleItems.indexOf(name) === -1) {
            report(node, `'${name}' module`, info)
        }
    }

    /**
     * Reports a use of a deprecated property.
     *
     * @param {ASTNode} node - A node to report.
     * @param {string[]} path - The path to a deprecated property.
     * @param {{since: number, replacedBy: string, global: boolean}} info - Information of the property.
     * @returns {void}
     */
    function reportCall(node, path, info) {
        const ignored = info.global ? ignoredGlobalItems : ignoredModuleItems
        const name = `${path.join(".")}()`

        if (ignored.indexOf(name) === -1) {
            report(node, `'${name}'`, info)
        }
    }

    /**
     * Reports a use of a deprecated property.
     *
     * @param {ASTNode} node - A node to report.
     * @param {string[]} path - The path to a deprecated property.
     * @param {{since: number, replacedBy: string, global: boolean}} info - Information of the property.
     * @returns {void}
     */
    function reportConstructor(node, path, info) {
        const ignored = info.global ? ignoredGlobalItems : ignoredModuleItems
        const name = `new ${path.join(".")}()`

        if (ignored.indexOf(name) === -1) {
            report(node, `'${name}'`, info)
        }
    }

    /**
     * Reports a use of a deprecated property.
     *
     * @param {ASTNode} node - A node to report.
     * @param {string[]} path - The path to a deprecated property.
     * @param {string} key - The name of the property.
     * @param {{since: number, replacedBy: string, global: boolean}} info - Information of the property.
     * @returns {void}
     */
    function reportProperty(node, path, key, info) {
        const ignored = info.global ? ignoredGlobalItems : ignoredModuleItems

        path.push(key)
        const name = path.join(".")
        path.pop()

        if (ignored.indexOf(name) === -1) {
            report(node, `'${name}'`, info)
        }
    }

    /**
     * Checks violations in destructuring assignments.
     *
     * @param {ASTNode} node - A pattern node to check.
     * @param {string[]} path - The path to a deprecated property.
     * @param {object} infoMap - A map of properties' information.
     * @returns {void}
     */
    function checkDestructuring(node, path, infoMap) {
        switch (node.type) {
            case "AssignmentPattern":
                checkDestructuring(node.left, path, infoMap)
                break

            case "Identifier": {
                const variable = findVariable(node, globalScope)
                if (variable != null) {
                    checkVariable(variable, path, infoMap)
                }
                break
            }
            case "ObjectPattern":
                for (const property of node.properties) {
                    const key = getPropertyName(property)
                    if (key != null && hasOwnProperty.call(infoMap, key)) {
                        const keyInfo = infoMap[key]
                        if (keyInfo.$deprecated) {
                            reportProperty(property.key, path, key, keyInfo)
                        }
                        else {
                            path.push(key)
                            checkDestructuring(property.value, path, keyInfo)
                            path.pop()
                        }
                    }
                }
                break

            // no default
        }
    }

    /**
     * Checks violations in properties.
     *
     * @param {ASTNode} root - A node to check.
     * @param {string[]} path - The path to a deprecated property.
     * @param {object} infoMap - A map of properties' information.
     * @returns {void}
     */
    function checkProperties(root, path, infoMap) { //eslint-disable-line complexity
        let node = root
        while (!SENTINEL_TYPE.test(node.parent.type)) {
            node = node.parent
        }

        const parent = node.parent
        switch (parent.type) {
            case "CallExpression":
                if (parent.callee === node && infoMap.$call != null) {
                    reportCall(parent, path, infoMap.$call)
                }
                break

            case "NewExpression":
                if (parent.callee === node && infoMap.$constructor != null) {
                    reportConstructor(parent, path, infoMap.$constructor)
                }
                break

            case "MemberExpression":
                if (parent.object === node) {
                    const key = getPropertyName(parent)
                    if (key != null && hasOwnProperty.call(infoMap, key)) {
                        const keyInfo = infoMap[key]
                        if (keyInfo.$deprecated) {
                            reportProperty(parent.property, path, key, keyInfo)
                        }
                        else {
                            path.push(key)
                            checkProperties(parent, path, keyInfo)
                            path.pop()
                        }
                    }
                }
                break

            case "AssignmentExpression":
                if (parent.right === node) {
                    checkDestructuring(parent.left, path, infoMap)
                    checkProperties(parent, path, infoMap)
                }
                break

            case "AssignmentPattern":
                if (parent.right === node) {
                    checkDestructuring(parent.left, path, infoMap)
                }
                break

            case "VariableDeclarator":
                if (parent.init === node) {
                    checkDestructuring(parent.id, path, infoMap)
                }
                break

            // no default
        }
    }

    /**
     * Checks violations in the references of a given variable.
     *
     * @param {escope.Variable} variable - A variable to check.
     * @param {string[]} path - The path to a deprecated property.
     * @param {object} infoMap - A map of properties' information.
     * @returns {void}
     */
    function checkVariable(variable, path, infoMap) {
        if (varStack.indexOf(variable) !== -1) {
            return
        }
        varStack.push(variable)

        if (infoMap.$deprecated) {
            const key = path.pop()
            for (const reference of variable.references.filter(r => r.isRead())) {
                reportProperty(reference.identifier, path, key, infoMap)
            }
        }
        else {
            for (const reference of variable.references.filter(r => r.isRead())) {
                checkProperties(reference.identifier, path, infoMap)
            }
        }

        varStack.pop()
    }

    /**
     * Checks violations in a ModuleSpecifier node.
     *
     * @param {ASTNode} node - A ModuleSpecifier node to check.
     * @param {string[]} path - The path to a deprecated property.
     * @param {object} infoMap - A map of properties' information.
     * @returns {void}
     */
    function checkImportSpecifier(node, path, infoMap) {
        switch (node.type) {
            case "ImportSpecifier": {
                const key = node.imported.name
                if (hasOwnProperty.call(infoMap, key)) {
                    const keyInfo = infoMap[key]
                    if (keyInfo.$deprecated) {
                        reportProperty(node.imported, path, key, keyInfo)
                    }
                    else {
                        path.push(key)
                        checkVariable(
                            findVariable(node.local, globalScope),
                            path,
                            keyInfo
                        )
                        path.pop()
                    }
                }
                break
            }
            case "ImportDefaultSpecifier":
                checkVariable(
                    findVariable(node.local, globalScope),
                    path,
                    infoMap
                )
                break

            case "ImportNamespaceSpecifier":
                checkVariable(
                    findVariable(node.local, globalScope),
                    path,
                    Object.assign({}, infoMap, { default: infoMap })
                )
                break

            // no default
        }
    }

    /**
     * Checks violations for CommonJS modules.
     * @returns {void}
     */
    function checkCommonJsModules() {
        const infoMap = deprecatedApis.modules
        const variable = globalScope.set.get("require")

        if (variable == null || variable.defs.length !== 0) {
            return
        }

        for (const reference of variable.references.filter(r => r.isRead())) {
            const id = reference.identifier
            const node = id.parent

            if (node.type === "CallExpression" && node.callee === id) {
                const key = getValueIfString(node.arguments[0])
                if (key != null && hasOwnProperty.call(infoMap, key)) {
                    const moduleInfo = infoMap[key]
                    if (moduleInfo.$deprecated) {
                        reportModule(node, key, moduleInfo)
                    }
                    else {
                        checkProperties(node, [key], moduleInfo)
                    }
                }
            }
        }
    }

    /**
     * Checks violations for ES2015 modules.
     * @param {ASTNode} programNode - A program node to check.
     * @returns {void}
     */
    function checkES2015Modules(programNode) {
        const infoMap = deprecatedApis.modules

        for (const node of programNode.body.filter(isImportDeclaration)) {
            const key = node.source.value
            if (hasOwnProperty.call(infoMap, key)) {
                const moduleInfo = infoMap[key]
                if (moduleInfo.$deprecated) {
                    reportModule(node, key, moduleInfo)
                }
                else {
                    for (const specifier of node.specifiers) {
                        checkImportSpecifier(specifier, [key], moduleInfo)
                    }
                }
            }
        }
    }

    /**
     * Checks violations for global variables.
     * @returns {void}
     */
    function checkGlobals() {
        const infoMap = deprecatedApis.globals

        for (const key of Object.keys(infoMap)) {
            const keyInfo = infoMap[key]
            const variable = globalScope.set.get(key)

            if (variable != null && variable.defs.length === 0) {
                checkVariable(variable, [key], keyInfo)
            }
        }
    }

    return {
        "Program:exit"(node) {
            globalScope = context.getScope()

            checkCommonJsModules()
            checkES2015Modules(node)
            checkGlobals()
        },
    }
}

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

module.exports = {
    create,
    meta: {
        docs: {
            description: "disallow deprecated APIs",
            category: "Best Practices",
            recommended: true,
            url: getDocsUrl("no-deprecated-api.md"),
        },
        fixable: false,
        schema: [
            {
                type: "object",
                properties: {
                    ignoreModuleItems: {
                        type: "array",
                        items: { enum: MODULE_ITEMS },
                        additionalItems: false,
                        uniqueItems: true,
                    },
                    ignoreGlobalItems: {
                        type: "array",
                        items: { enum: GLOBAL_ITEMS },
                        additionalItems: false,
                        uniqueItems: true,
                    },

                    // Deprecated since v4.2.0
                    ignoreIndirectDependencies: { type: "boolean" },
                },
                additionalProperties: false,
            },
        ],
    },
}
