/**
 * @fileoverview prevent variables defined in `<script setup>` to be marked as undefined
 * @author Yosuke Ota
 */
'use strict'

const Module = require('module')
const path = require('path')
const utils = require('../utils')
const AST = require('vue-eslint-parser').AST

const ecmaVersion = 2020

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

module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description:
        'prevent variables defined in `<script setup>` to be marked as undefined', // eslint-disable-line consistent-docs-description
      categories: ['base'],
      url: 'https://eslint.vuejs.org/rules/experimental-script-setup-vars.html'
    },
    schema: []
  },
  /**
   * @param {RuleContext} context - The rule context.
   * @returns {RuleListener} AST event handlers.
   */
  create(context) {
    const documentFragment =
      context.parserServices.getDocumentFragment &&
      context.parserServices.getDocumentFragment()
    if (!documentFragment) {
      return {}
    }
    const sourceCode = context.getSourceCode()
    const scriptElement = documentFragment.children
      .filter(utils.isVElement)
      .find(
        (element) =>
          element.name === 'script' &&
          element.range[0] <= sourceCode.ast.range[0] &&
          sourceCode.ast.range[1] <= element.range[1]
      )
    if (!scriptElement) {
      return {}
    }
    const setupAttr = utils.getAttribute(scriptElement, 'setup')
    if (!setupAttr || !setupAttr.value) {
      return {}
    }
    const value = setupAttr.value.value

    let eslintScope
    try {
      eslintScope = getESLintModule('eslint-scope', () =>
        // @ts-ignore
        require('eslint-scope')
      )
    } catch (_e) {
      context.report({
        node: setupAttr,
        message: 'Can not be resolved eslint-scope.'
      })
      return {}
    }
    let espree
    try {
      espree = getESLintModule('espree', () =>
        // @ts-ignore
        require('espree')
      )
    } catch (_e) {
      context.report({
        node: setupAttr,
        message: 'Can not be resolved espree.'
      })
      return {}
    }

    const globalScope = sourceCode.scopeManager.scopes[0]

    /** @type {string[]} */
    let vars
    try {
      vars = parseSetup(value, espree, eslintScope)
    } catch (_e) {
      context.report({
        node: setupAttr.value,
        message: 'Parsing error.'
      })
      return {}
    }

    // Define configured global variables.
    for (const id of vars) {
      const tempVariable = globalScope.set.get(id)

      /** @type {Variable} */
      let variable
      if (!tempVariable) {
        variable = new eslintScope.Variable(id, globalScope)

        globalScope.variables.push(variable)
        globalScope.set.set(id, variable)
      } else {
        variable = tempVariable
      }

      variable.eslintImplicitGlobalSetting = 'readonly'
      variable.eslintExplicitGlobal = undefined
      variable.eslintExplicitGlobalComments = undefined
      variable.writeable = false
    }

    /*
     * "through" contains all references which definitions cannot be found.
     * Since we augment the global scope using configuration, we need to update
     * references and remove the ones that were added by configuration.
     */
    globalScope.through = globalScope.through.filter((reference) => {
      const name = reference.identifier.name
      const variable = globalScope.set.get(name)

      if (variable) {
        /*
         * Links the variable and the reference.
         * And this reference is removed from `Scope#through`.
         */
        reference.resolved = variable
        variable.references.push(reference)

        return false
      }

      return true
    })

    return {}
  }
}

/**
 * @param {string} code
 * @param {any} espree
 * @param {any} eslintScope
 * @returns {string[]}
 */
function parseSetup(code, espree, eslintScope) {
  /** @type {Program} */
  const ast = espree.parse(`(${code})=>{}`, { ecmaVersion })
  const result = eslintScope.analyze(ast, {
    ignoreEval: true,
    nodejsScope: false,
    ecmaVersion,
    sourceType: 'script',
    fallback: AST.getFallbackKeys
  })

  const variables = /** @type {Variable[]} */ (
    result.globalScope.childScopes[0].variables
  )

  return variables.map((v) => v.name)
}

const createRequire =
  // Added in v12.2.0
  Module.createRequire ||
  // Added in v10.12.0, but deprecated in v12.2.0.
  Module.createRequireFromPath ||
  // Polyfill - This is not executed on the tests on node@>=10.
  /**
   * @param {string} filename
   */
  function (filename) {
    const mod = new Module(filename)

    mod.filename = filename
    // @ts-ignore
    mod.paths = Module._nodeModulePaths(path.dirname(filename))
    // @ts-ignore
    mod._compile('module.exports = require;', filename)
    return mod.exports
  }

/** @type { { 'espree'?: any, 'eslint-scope'?: any } } */
const modulesCache = {}

/**
 * @param {string} p
 */
function isLinterPath(p) {
  return (
    // ESLint 6 and above
    p.includes(`eslint${path.sep}lib${path.sep}linter${path.sep}linter.js`) ||
    // ESLint 5
    p.includes(`eslint${path.sep}lib${path.sep}linter.js`)
  )
}

/**
 * Load module from the loaded ESLint.
 * If the loaded ESLint was not found, just returns `fallback()`.
 * @param {'espree' | 'eslint-scope'} name
 * @param { () => any } fallback
 */
function getESLintModule(name, fallback) {
  if (!modulesCache[name]) {
    // Lookup the loaded eslint
    const linterPath = Object.keys(require.cache).find(isLinterPath)
    if (linterPath) {
      try {
        modulesCache[name] = createRequire(linterPath)(name)
      } catch (_e) {
        // ignore
      }
    }
    if (!modulesCache[name]) {
      modulesCache[name] = fallback()
    }
  }

  return modulesCache[name]
}
