/**
 * @author Toru Nagashima
 * @copyright 2016 Toru Nagashima. All rights reserved.
 * See LICENSE file in root directory for full license.
 */
'use strict'

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

const utils = require('../utils')

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

/**
 * These strings wil be displayed in error messages.
 */
const ELEMENT_TYPE_MESSAGES = Object.freeze({
  NORMAL: 'HTML elements',
  VOID: 'HTML void elements',
  COMPONENT: 'Vue.js custom components',
  SVG: 'SVG elements',
  MATH: 'MathML elements',
  UNKNOWN: 'unknown elements'
})

/**
 * @typedef {object} Options
 * @property {'always' | 'never'} NORMAL
 * @property {'always' | 'never'} VOID
 * @property {'always' | 'never'} COMPONENT
 * @property {'always' | 'never'} SVG
 * @property {'always' | 'never'} MATH
 * @property {null} UNKNOWN
 */

/**
 * Normalize the given options.
 * @param {any} options The raw options object.
 * @returns {Options} Normalized options.
 */
function parseOptions(options) {
  return {
    NORMAL: (options && options.html && options.html.normal) || 'always',
    VOID: (options && options.html && options.html.void) || 'never',
    COMPONENT: (options && options.html && options.html.component) || 'always',
    SVG: (options && options.svg) || 'always',
    MATH: (options && options.math) || 'always',
    UNKNOWN: null
  }
}

/**
 * Get the elementType of the given element.
 * @param {VElement} node The element node to get.
 * @returns {keyof Options} The elementType of the element.
 */
function getElementType(node) {
  if (utils.isCustomComponent(node)) {
    return 'COMPONENT'
  }
  if (utils.isHtmlElementNode(node)) {
    if (utils.isHtmlVoidElementName(node.name)) {
      return 'VOID'
    }
    return 'NORMAL'
  }
  if (utils.isSvgElementNode(node)) {
    return 'SVG'
  }
  if (utils.isMathMLElementNode(node)) {
    return 'MATH'
  }
  return 'UNKNOWN'
}

/**
 * Check whether the given element is empty or not.
 * This ignores whitespaces, doesn't ignore comments.
 * @param {VElement} node The element node to check.
 * @param {SourceCode} sourceCode The source code object of the current context.
 * @returns {boolean} `true` if the element is empty.
 */
function isEmpty(node, sourceCode) {
  const start = node.startTag.range[1]
  const end = node.endTag != null ? node.endTag.range[0] : node.range[1]

  return sourceCode.text.slice(start, end).trim() === ''
}

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

module.exports = {
  meta: {
    type: 'layout',
    docs: {
      description: 'enforce self-closing style',
      categories: ['vue3-strongly-recommended', 'strongly-recommended'],
      url: 'https://eslint.vuejs.org/rules/html-self-closing.html'
    },
    fixable: 'code',
    schema: {
      definitions: {
        optionValue: {
          enum: ['always', 'never', 'any']
        }
      },
      type: 'array',
      items: [
        {
          type: 'object',
          properties: {
            html: {
              type: 'object',
              properties: {
                normal: { $ref: '#/definitions/optionValue' },
                void: { $ref: '#/definitions/optionValue' },
                component: { $ref: '#/definitions/optionValue' }
              },
              additionalProperties: false
            },
            svg: { $ref: '#/definitions/optionValue' },
            math: { $ref: '#/definitions/optionValue' }
          },
          additionalProperties: false
        }
      ],
      maxItems: 1
    }
  },
  /** @param {RuleContext} context */
  create(context) {
    const sourceCode = context.getSourceCode()
    const options = parseOptions(context.options[0])
    let hasInvalidEOF = false

    return utils.defineTemplateBodyVisitor(
      context,
      {
        VElement(node) {
          if (hasInvalidEOF) {
            return
          }

          const elementType = getElementType(node)
          const mode = options[elementType]

          if (
            mode === 'always' &&
            !node.startTag.selfClosing &&
            isEmpty(node, sourceCode)
          ) {
            context.report({
              node,
              loc: node.loc,
              message: 'Require self-closing on {{elementType}} (<{{name}}>).',
              data: {
                elementType: ELEMENT_TYPE_MESSAGES[elementType],
                name: node.rawName
              },
              fix(fixer) {
                const tokens =
                  context.parserServices.getTemplateBodyTokenStore()
                const close = tokens.getLastToken(node.startTag)
                if (close.type !== 'HTMLTagClose') {
                  return null
                }
                return fixer.replaceTextRange(
                  [close.range[0], node.range[1]],
                  '/>'
                )
              }
            })
          }

          if (mode === 'never' && node.startTag.selfClosing) {
            context.report({
              node,
              loc: node.loc,
              message:
                'Disallow self-closing on {{elementType}} (<{{name}}/>).',
              data: {
                elementType: ELEMENT_TYPE_MESSAGES[elementType],
                name: node.rawName
              },
              fix(fixer) {
                const tokens =
                  context.parserServices.getTemplateBodyTokenStore()
                const close = tokens.getLastToken(node.startTag)
                if (close.type !== 'HTMLSelfClosingTagClose') {
                  return null
                }
                if (elementType === 'VOID') {
                  return fixer.replaceText(close, '>')
                }
                // If only `close` is targeted for replacement, it conflicts with `component-name-in-template-casing`,
                // so replace the entire element.
                // return fixer.replaceText(close, `></${node.rawName}>`)
                const elementPart = sourceCode.text.slice(
                  node.range[0],
                  close.range[0]
                )
                return fixer.replaceText(
                  node,
                  `${elementPart}></${node.rawName}>`
                )
              }
            })
          }
        }
      },
      {
        Program(node) {
          hasInvalidEOF = utils.hasInvalidEOF(node)
        }
      }
    )
  }
}
