/**
 * @fileoverview Keep order of properties in components
 * @author Michał Sajnóg
 */
'use strict'

const utils = require('../utils')
const traverseNodes = require('vue-eslint-parser').AST.traverseNodes

/**
 * @typedef {import('eslint-visitor-keys').VisitorKeys} VisitorKeys
 */

const defaultOrder = [
  // Side Effects (triggers effects outside the component)
  'el',

  // Global Awareness (requires knowledge beyond the component)
  'name',
  'key', // for Nuxt
  'parent',

  // Component Type (changes the type of the component)
  'functional',

  // Template Modifiers (changes the way templates are compiled)
  ['delimiters', 'comments'],

  // Template Dependencies (assets used in the template)
  ['components', 'directives', 'filters'],

  // Composition (merges properties into the options)
  'extends',
  'mixins',
  ['provide', 'inject'], // for Vue.js 2.2.0+

  // Page Options (component rendered as a router page)
  'ROUTER_GUARDS', // for Vue Router
  'layout', // for Nuxt
  'middleware', // for Nuxt
  'validate', // for Nuxt
  'scrollToTop', // for Nuxt
  'transition', // for Nuxt
  'loading', // for Nuxt

  // Interface (the interface to the component)
  'inheritAttrs',
  'model',
  ['props', 'propsData'],
  'emits', // for Vue.js 3.x

  // Note:
  // The `setup` option is included in the "Composition" category,
  // but the behavior of the `setup` option requires the definition of "Interface",
  // so we prefer to put the `setup` option after the "Interface".
  'setup', // for Vue 3.x

  // Local State (local reactive properties)
  'asyncData', // for Nuxt
  'data',
  'fetch', // for Nuxt
  'head', // for Nuxt
  'computed',

  // Events (callbacks triggered by reactive events)
  'watch',
  'watchQuery', // for Nuxt
  'LIFECYCLE_HOOKS',

  // Non-Reactive Properties (instance properties independent of the reactivity system)
  'methods',

  // Rendering (the declarative description of the component output)
  ['template', 'render'],
  'renderError'
]

/** @type { { [key: string]: string[] } } */
const groups = {
  LIFECYCLE_HOOKS: [
    'beforeCreate',
    'created',
    'beforeMount',
    'mounted',
    'beforeUpdate',
    'updated',
    'activated',
    'deactivated',
    'beforeUnmount', // for Vue.js 3.x
    'unmounted', // for Vue.js 3.x
    'beforeDestroy',
    'destroyed',
    'renderTracked', // for Vue.js 3.x
    'renderTriggered', // for Vue.js 3.x
    'errorCaptured' // for Vue.js 2.5.0+
  ],
  ROUTER_GUARDS: ['beforeRouteEnter', 'beforeRouteUpdate', 'beforeRouteLeave']
}

/**
 * @param {(string | string[])[]} order
 */
function getOrderMap(order) {
  /** @type {Map<string, number>} */
  const orderMap = new Map()

  order.forEach((property, i) => {
    if (Array.isArray(property)) {
      property.forEach((p) => orderMap.set(p, i))
    } else {
      orderMap.set(property, i)
    }
  })

  return orderMap
}

/**
 * @param {Token} node
 */
function isComma(node) {
  return node.type === 'Punctuator' && node.value === ','
}

const ARITHMETIC_OPERATORS = ['+', '-', '*', '/', '%', '**' /* es2016 */]
const BITWISE_OPERATORS = ['&', '|', '^', '~', '<<', '>>', '>>>']
const COMPARISON_OPERATORS = ['==', '!=', '===', '!==', '>', '>=', '<', '<=']
const RELATIONAL_OPERATORS = ['in', 'instanceof']
const ALL_BINARY_OPERATORS = [
  ...ARITHMETIC_OPERATORS,
  ...BITWISE_OPERATORS,
  ...COMPARISON_OPERATORS,
  ...RELATIONAL_OPERATORS
]
const LOGICAL_OPERATORS = ['&&', '||', '??' /* es2020 */]

/**
 * Result `true` if the node is sure that there are no side effects
 *
 * Currently known side effects types
 *
 * node.type === 'CallExpression'
 * node.type === 'NewExpression'
 * node.type === 'UpdateExpression'
 * node.type === 'AssignmentExpression'
 * node.type === 'TaggedTemplateExpression'
 * node.type === 'UnaryExpression' && node.operator === 'delete'
 *
 * @param  {ASTNode} node target node
 * @param  {VisitorKeys} visitorKeys sourceCode.visitorKey
 * @returns {boolean} no side effects
 */
function isNotSideEffectsNode(node, visitorKeys) {
  let result = true
  /** @type {ASTNode | null} */
  let skipNode = null
  traverseNodes(node, {
    visitorKeys,
    /** @param {ASTNode} node */
    enterNode(node) {
      if (!result || skipNode) {
        return
      }

      if (
        // no side effects node
        node.type === 'FunctionExpression' ||
        node.type === 'Identifier' ||
        node.type === 'Literal' ||
        // es2015
        node.type === 'ArrowFunctionExpression' ||
        node.type === 'TemplateElement'
      ) {
        skipNode = node
      } else if (
        node.type !== 'Property' &&
        node.type !== 'ObjectExpression' &&
        node.type !== 'ArrayExpression' &&
        (node.type !== 'UnaryExpression' ||
          !['!', '~', '+', '-', 'typeof'].includes(node.operator)) &&
        (node.type !== 'BinaryExpression' ||
          !ALL_BINARY_OPERATORS.includes(node.operator)) &&
        (node.type !== 'LogicalExpression' ||
          !LOGICAL_OPERATORS.includes(node.operator)) &&
        node.type !== 'MemberExpression' &&
        node.type !== 'ConditionalExpression' &&
        // es2015
        node.type !== 'SpreadElement' &&
        node.type !== 'TemplateLiteral' &&
        // es2020
        node.type !== 'ChainExpression'
      ) {
        // Can not be sure that a node has no side effects
        result = false
      }
    },
    /** @param {ASTNode} node */
    leaveNode(node) {
      if (skipNode === node) {
        skipNode = null
      }
    }
  })

  return result
}

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

module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'enforce order of properties in components',
      categories: ['vue3-recommended', 'recommended'],
      url: 'https://eslint.vuejs.org/rules/order-in-components.html'
    },
    fixable: 'code', // null or "code" or "whitespace"
    schema: [
      {
        type: 'object',
        properties: {
          order: {
            type: 'array'
          }
        },
        additionalProperties: false
      }
    ]
  },
  /** @param {RuleContext} context */
  create(context) {
    const options = context.options[0] || {}
    /** @type {(string|string[])[]} */
    const order = options.order || defaultOrder
    /** @type {(string|string[])[]} */
    const extendedOrder = order.map(
      (property) =>
        (typeof property === 'string' && groups[property]) || property
    )
    const orderMap = getOrderMap(extendedOrder)
    const sourceCode = context.getSourceCode()

    /**
     * @param {string} name
     */
    function getOrderPosition(name) {
      const num = orderMap.get(name)
      return num == null ? -1 : num
    }

    /**
     * @param {(Property | SpreadElement)[]} propertiesNodes
     */
    function checkOrder(propertiesNodes) {
      const properties = propertiesNodes
        .filter(utils.isProperty)
        .map((property) => {
          return {
            node: property,
            name:
              utils.getStaticPropertyName(property) ||
              (property.key.type === 'Identifier' && property.key.name) ||
              ''
          }
        })

      properties.forEach((property, i) => {
        const orderPos = getOrderPosition(property.name)
        if (orderPos < 0) {
          return
        }
        const propertiesAbove = properties.slice(0, i)
        const unorderedProperties = propertiesAbove
          .filter(
            (p) => getOrderPosition(p.name) > getOrderPosition(property.name)
          )
          .sort((p1, p2) =>
            getOrderPosition(p1.name) > getOrderPosition(p2.name) ? 1 : -1
          )

        const firstUnorderedProperty = unorderedProperties[0]

        if (firstUnorderedProperty) {
          const line = firstUnorderedProperty.node.loc.start.line
          context.report({
            node: property.node,
            message: `The "{{name}}" property should be above the "{{firstUnorderedPropertyName}}" property on line {{line}}.`,
            data: {
              name: property.name,
              firstUnorderedPropertyName: firstUnorderedProperty.name,
              line
            },
            *fix(fixer) {
              const propertyNode = property.node
              const firstUnorderedPropertyNode = firstUnorderedProperty.node
              const hasSideEffectsPossibility = propertiesNodes
                .slice(
                  propertiesNodes.indexOf(firstUnorderedPropertyNode),
                  propertiesNodes.indexOf(propertyNode) + 1
                )
                .some(
                  (property) =>
                    !isNotSideEffectsNode(property, sourceCode.visitorKeys)
                )
              if (hasSideEffectsPossibility) {
                return
              }
              const afterComma = sourceCode.getTokenAfter(propertyNode)
              const hasAfterComma = isComma(afterComma)

              const beforeComma = sourceCode.getTokenBefore(propertyNode)
              const codeStart = beforeComma.range[1] // to include comments
              const codeEnd = hasAfterComma
                ? afterComma.range[1]
                : propertyNode.range[1]

              const removeStart = hasAfterComma
                ? codeStart
                : beforeComma.range[0]
              yield fixer.removeRange([removeStart, codeEnd])

              const propertyCode =
                sourceCode.text.slice(codeStart, codeEnd) +
                (hasAfterComma ? '' : ',')
              const insertTarget = sourceCode.getTokenBefore(
                firstUnorderedPropertyNode
              )

              yield fixer.insertTextAfter(insertTarget, propertyCode)
            }
          })
        }
      })
    }

    return utils.executeOnVue(context, (obj) => {
      checkOrder(obj.properties)
    })
  }
}
