/**
 * @author Yosuke Ota
 * @copyright 2021 Yosuke Ota. All rights reserved.
 * See LICENSE file in root directory for full license.
 */
'use strict'

const utils = require('./index')
const eslintUtils = require('@eslint-community/eslint-utils')

/**
 * @typedef {import('./style-variables').StyleVariablesContext} StyleVariablesContext
 */
/**
 * @typedef {object} IHasPropertyOption
 * @property {boolean} [unknownCallAsAny]
 */
/**
 * @typedef {object} NestPropertyNodeForExpression
 * @property {'expression'} type
 * @property {MemberExpression} node
 *
 * @typedef {object} NestPropertyNodeForPattern
 * @property {'pattern'} type
 * @property {MemberExpression | Identifier | ObjectPattern | ArrayPattern} node
 *
 * @typedef {NestPropertyNodeForExpression | NestPropertyNodeForPattern} NestPropertyNode
 */
/**
 * @typedef {object} IPropertyReferences
 * @property { (name: string, option?: IHasPropertyOption) => boolean } hasProperty
 * @property { () => Map<string, {nodes:ASTNode[]}> } allProperties Get all properties.
 * @property { (name: string) => IPropertyReferences } getNest Get the nesting property references.
 * @property { (name: string) => Iterable<NestPropertyNode> } getNestNodes Get the nesting property nodes.
 */

/** @type {IPropertyReferences} */
const ANY = {
  hasProperty: () => true,
  allProperties: () => new Map(),
  getNest: () => ANY,
  getNestNodes: () => []
}

/** @type {IPropertyReferences} */
const NEVER = {
  hasProperty: () => false,
  allProperties: () => new Map(),
  getNest: () => NEVER,
  getNestNodes: () => []
}

/**
 * @param {RuleContext} context
 * @param {Identifier} id
 * @returns {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration | null}
 */
function findFunction(context, id) {
  const calleeVariable = utils.findVariableByIdentifier(context, id)
  if (!calleeVariable) {
    return null
  }
  if (calleeVariable.defs.length === 1) {
    const def = calleeVariable.defs[0]
    if (def.node.type === 'FunctionDeclaration') {
      return def.node
    }
    if (
      def.type === 'Variable' &&
      def.parent.kind === 'const' &&
      def.node.init
    ) {
      if (
        def.node.init.type === 'FunctionExpression' ||
        def.node.init.type === 'ArrowFunctionExpression'
      ) {
        return def.node.init
      }
      if (def.node.init.type === 'Identifier') {
        return findFunction(context, def.node.init)
      }
    }
  }
  return null
}

module.exports = {
  definePropertyReferenceExtractor,
  mergePropertyReferences
}

/**
 * @param {RuleContext} context The rule context.
 */
function definePropertyReferenceExtractor(
  context,
  { unknownMemberAsUnreferenced = false, returnAsUnreferenced = false } = {}
) {
  /** @type {Map<Expression, IPropertyReferences>} */
  const cacheForExpression = new Map()
  /** @type {Map<Pattern, IPropertyReferences>} */
  const cacheForPattern = new Map()
  /** @type {Map<FunctionExpression | ArrowFunctionExpression | FunctionDeclaration, Map<number, IPropertyReferences>>} */
  const cacheForFunction = new Map()
  /** @type {{ toRefNodes: Set<ESNode>, toRefsNodes: Set<ESNode>} | null} */
  let toRefSet = null

  let isFunctionalTemplate = false
  const templateBody = context.getSourceCode().ast.templateBody
  if (templateBody) {
    isFunctionalTemplate = utils.hasAttribute(templateBody, 'functional')
  }

  function getToRefSet() {
    if (toRefSet) {
      return toRefSet
    }
    const tracker = new eslintUtils.ReferenceTracker(
      context.getSourceCode().scopeManager.scopes[0]
    )
    const toRefNodes = new Set()
    for (const { node } of utils.iterateReferencesTraceMap(tracker, {
      [eslintUtils.ReferenceTracker.ESM]: true,
      toRef: {
        [eslintUtils.ReferenceTracker.CALL]: true
      }
    })) {
      toRefNodes.add(node)
    }
    const toRefsNodes = new Set()
    for (const { node } of utils.iterateReferencesTraceMap(tracker, {
      [eslintUtils.ReferenceTracker.ESM]: true,
      toRefs: {
        [eslintUtils.ReferenceTracker.CALL]: true
      }
    })) {
      toRefsNodes.add(node)
    }

    return (toRefSet = { toRefNodes, toRefsNodes })
  }

  /**
   * Collects the property references for member expr.
   * @implements {IPropertyReferences}
   */
  class PropertyReferencesForMember {
    /**
     *
     * @param {MemberExpression} node
     * @param {string} name
     * @param {boolean} withInTemplate
     */
    constructor(node, name, withInTemplate) {
      this.node = node
      this.name = name
      this.withInTemplate = withInTemplate
    }

    /**
     * @param {string} name
     */
    hasProperty(name) {
      return name === this.name
    }

    allProperties() {
      return new Map([[this.name, { nodes: [this.node.property] }]])
    }

    /**
     * @param {string} name
     * @returns {IPropertyReferences}
     */
    getNest(name) {
      return name === this.name
        ? extractFromExpression(this.node, this.withInTemplate)
        : NEVER
    }

    /**
     * @param {string} name
     * @returns {Iterable<NestPropertyNodeForExpression>}
     */
    *getNestNodes(name) {
      if (name === this.name) {
        yield {
          type: 'expression',
          node: this.node
        }
      }
    }
  }
  /**
   * Collects the property references for object.
   * @implements {IPropertyReferences}
   */
  class PropertyReferencesForObject {
    constructor() {
      /** @type {Record<string, AssignmentProperty[]>} */
      this.properties = Object.create(null)
    }

    /**
     * @param {string} name
     */
    hasProperty(name) {
      return Boolean(this.properties[name])
    }

    allProperties() {
      const result = new Map()
      for (const [name, nodes] of Object.entries(this.properties)) {
        result.set(name, { nodes: nodes.map((node) => node.key) })
      }
      return result
    }

    /**
     * @param {string} name
     * @returns {IPropertyReferences}
     */
    getNest(name) {
      const properties = this.properties[name]
      return properties
        ? mergePropertyReferences(
            properties.map((property) => getNestFromPattern(property.value))
          )
        : NEVER
    }

    /**
     * @param {string} name
     * @returns {Iterable<NestPropertyNodeForPattern>}
     */
    *getNestNodes(name) {
      const properties = this.properties[name]
      if (!properties) {
        return
      }
      const values = properties.map((property) => property.value)
      let node
      while ((node = values.shift())) {
        if (
          node.type === 'Identifier' ||
          node.type === 'MemberExpression' ||
          node.type === 'ObjectPattern' ||
          node.type === 'ArrayPattern'
        ) {
          yield {
            type: 'pattern',
            node
          }
        } else if (node.type === 'AssignmentPattern') {
          values.unshift(node.left)
        }
      }
      return properties ? properties.map((p) => p.value) : []
    }
  }

  /**
   * @param {Pattern} pattern
   * @returns {IPropertyReferences}
   */
  function getNestFromPattern(pattern) {
    if (pattern.type === 'ObjectPattern') {
      return extractFromObjectPattern(pattern)
    }
    if (pattern.type === 'Identifier') {
      return extractFromIdentifier(pattern)
    } else if (pattern.type === 'AssignmentPattern') {
      return getNestFromPattern(pattern.left)
    }
    return ANY
  }

  /**
   * Extract the property references from Expression.
   * @param {Identifier | MemberExpression | ChainExpression | ThisExpression | CallExpression} node
   * @param {boolean} withInTemplate
   * @returns {IPropertyReferences}
   */
  function extractFromExpression(node, withInTemplate) {
    const ref = cacheForExpression.get(node)
    if (ref) {
      return ref
    }
    cacheForExpression.set(node, ANY)
    const result = extractWithoutCache()
    cacheForExpression.set(node, result)
    return result

    function extractWithoutCache() {
      const parent = node.parent
      switch (parent.type) {
        case 'AssignmentExpression': {
          // `({foo} = arg)`
          return !withInTemplate &&
            parent.right === node &&
            parent.operator === '='
            ? extractFromPattern(parent.left)
            : NEVER
        }
        case 'VariableDeclarator': {
          // `const {foo} = arg`
          // `const foo = arg`
          return !withInTemplate && parent.init === node
            ? extractFromPattern(parent.id)
            : NEVER
        }
        case 'MemberExpression': {
          if (parent.object === node) {
            // `arg.foo`
            const name = utils.getStaticPropertyName(parent)

            if (
              name === '$props' &&
              parent.parent.type === 'MemberExpression'
            ) {
              // `$props.arg`
              const propName = utils.getStaticPropertyName(parent.parent)

              if (!propName) return unknownMemberAsUnreferenced ? NEVER : ANY

              return new PropertyReferencesForMember(
                parent.parent,
                propName,
                withInTemplate
              )
            } else if (name) {
              return new PropertyReferencesForMember(
                parent,
                name,
                withInTemplate
              )
            } else {
              return unknownMemberAsUnreferenced ? NEVER : ANY
            }
          }
          return NEVER
        }
        case 'CallExpression': {
          const argIndex = parent.arguments.indexOf(node)
          // `foo(arg)`
          return !withInTemplate && argIndex > -1
            ? extractFromCall(parent, argIndex)
            : NEVER
        }
        case 'ChainExpression': {
          return extractFromExpression(parent, withInTemplate)
        }
        case 'ArrowFunctionExpression':
        case 'VExpressionContainer':
        case 'Property':
        case 'ArrayExpression': {
          return maybeExternalUsed(parent) ? ANY : NEVER
        }
        case 'ReturnStatement': {
          if (returnAsUnreferenced) {
            return NEVER
          } else {
            return maybeExternalUsed(parent) ? ANY : NEVER
          }
        }
      }
      return NEVER
    }

    /**
     * @param {ASTNode} parentTarget
     * @returns {boolean}
     */
    function maybeExternalUsed(parentTarget) {
      if (
        parentTarget.type === 'ReturnStatement' ||
        parentTarget.type === 'VExpressionContainer'
      ) {
        return true
      }
      if (parentTarget.type === 'ArrayExpression') {
        return maybeExternalUsed(parentTarget.parent)
      }
      if (parentTarget.type === 'Property') {
        return maybeExternalUsed(parentTarget.parent.parent)
      }
      if (parentTarget.type === 'ArrowFunctionExpression') {
        return parentTarget.body === node
      }
      return false
    }
  }

  /**
   * Extract the property references from one parameter of the function.
   * @param {Pattern} node
   * @returns {IPropertyReferences}
   */
  function extractFromPattern(node) {
    const ref = cacheForPattern.get(node)
    if (ref) {
      return ref
    }
    cacheForPattern.set(node, ANY)
    const result = extractWithoutCache()
    cacheForPattern.set(node, result)
    return result

    function extractWithoutCache() {
      while (node.type === 'AssignmentPattern') {
        node = node.left
      }
      if (node.type === 'RestElement' || node.type === 'ArrayPattern') {
        // cannot check
        return NEVER
      }
      if (node.type === 'ObjectPattern') {
        return extractFromObjectPattern(node)
      }
      if (node.type === 'Identifier') {
        return extractFromIdentifier(node)
      }
      return NEVER
    }
  }

  /**
   * Extract the property references from ObjectPattern.
   * @param {ObjectPattern} node
   * @returns {IPropertyReferences}
   */
  function extractFromObjectPattern(node) {
    const refs = new PropertyReferencesForObject()
    for (const prop of node.properties) {
      if (prop.type === 'Property') {
        const name = utils.getStaticPropertyName(prop)
        if (name) {
          const list = refs.properties[name] || (refs.properties[name] = [])
          list.push(prop)
        } else {
          // If cannot trace name, everything is used!
          return ANY
        }
      } else {
        // If use RestElement, everything is used!
        return ANY
      }
    }
    return refs
  }

  /**
   * Extract the property references from id.
   * @param {Identifier} node
   * @returns {IPropertyReferences}
   */
  function extractFromIdentifier(node) {
    const variable = utils.findVariableByIdentifier(context, node)
    if (!variable) {
      return NEVER
    }
    return mergePropertyReferences(
      variable.references.map((reference) => {
        const id = reference.identifier
        return extractFromExpression(id, false)
      })
    )
  }

  /**
   * Extract the property references from call.
   * @param {CallExpression} node
   * @param {number} argIndex
   * @returns {IPropertyReferences}
   */
  function extractFromCall(node, argIndex) {
    if (node.callee.type !== 'Identifier') {
      return {
        hasProperty(_name, options) {
          return Boolean(options && options.unknownCallAsAny)
        },
        allProperties: () => new Map(),
        getNest: () => ANY,
        getNestNodes: () => []
      }
    }

    const fnNode = findFunction(context, node.callee)
    if (!fnNode) {
      if (argIndex === 0) {
        if (getToRefSet().toRefNodes.has(node)) {
          return extractFromToRef(node)
        } else if (getToRefSet().toRefsNodes.has(node)) {
          return extractFromToRefs(node)
        }
      }
      return {
        hasProperty(_name, options) {
          return Boolean(options && options.unknownCallAsAny)
        },
        allProperties: () => new Map(),
        getNest: () => ANY,
        getNestNodes: () => []
      }
    }

    return extractFromFunctionParam(fnNode, argIndex)
  }

  /**
   * Extract the property references from function param.
   * @param {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} node
   * @param {number} argIndex
   * @returns {IPropertyReferences}
   */
  function extractFromFunctionParam(node, argIndex) {
    let cacheForIndexes = cacheForFunction.get(node)
    if (!cacheForIndexes) {
      cacheForIndexes = new Map()
      cacheForFunction.set(node, cacheForIndexes)
    }
    const ref = cacheForIndexes.get(argIndex)
    if (ref) {
      return ref
    }
    cacheForIndexes.set(argIndex, NEVER)
    const arg = node.params[argIndex]
    if (!arg) {
      return NEVER
    }
    const result = extractFromPattern(arg)
    cacheForIndexes.set(argIndex, result)
    return result
  }

  /**
   * Extract the property references from path.
   * @param {string} pathString
   * @param {Identifier | Literal | TemplateLiteral} node
   * @returns {IPropertyReferences}
   */
  function extractFromPath(pathString, node) {
    return extractFromSegments(pathString.split('.'))

    /**
     * @param {string[]} segments
     * @returns {IPropertyReferences}
     */
    function extractFromSegments(segments) {
      if (segments.length === 0) {
        return ANY
      }
      const segmentName = segments[0]
      return {
        hasProperty: (name) => name === segmentName,
        allProperties: () => new Map([[segmentName, { nodes: [node] }]]),
        getNest: (name) =>
          name === segmentName ? extractFromSegments(segments.slice(1)) : NEVER,
        getNestNodes: () => []
      }
    }
  }

  /**
   * Extract the property references from name literal.
   * @param {Expression} node
   * @returns {IPropertyReferences}
   */
  function extractFromNameLiteral(node) {
    const referenceName =
      node.type === 'Literal' || node.type === 'TemplateLiteral'
        ? utils.getStringLiteralValue(node)
        : null
    return referenceName
      ? {
          hasProperty: (name) => name === referenceName,
          allProperties: () => new Map([[referenceName, { nodes: [node] }]]),
          getNest: (name) => (name === referenceName ? ANY : NEVER),
          getNestNodes: () => []
        }
      : NEVER
  }

  /**
   * Extract the property references from name.
   * @param {string} referenceName
   * @param {Expression|SpreadElement} nameNode
   * @param { () => IPropertyReferences } [getNest]
   * @returns {IPropertyReferences}
   */
  function extractFromName(referenceName, nameNode, getNest) {
    return {
      hasProperty: (name) => name === referenceName,
      allProperties: () => new Map([[referenceName, { nodes: [nameNode] }]]),
      getNest: (name) =>
        name === referenceName ? (getNest ? getNest() : ANY) : NEVER,
      getNestNodes: () => []
    }
  }

  /**
   * Extract the property references from toRef call.
   * @param {CallExpression} node
   * @returns {IPropertyReferences}
   */
  function extractFromToRef(node) {
    const nameNode = node.arguments[1]
    const refName =
      nameNode &&
      (nameNode.type === 'Literal' || nameNode.type === 'TemplateLiteral')
        ? utils.getStringLiteralValue(nameNode)
        : null
    if (!refName) {
      // unknown name
      return ANY
    }
    return extractFromName(refName, nameNode, () =>
      extractFromExpression(node, false).getNest('value')
    )
  }

  /**
   * Extract the property references from toRefs call.
   * @param {CallExpression} node
   * @returns {IPropertyReferences}
   */
  function extractFromToRefs(node) {
    const base = extractFromExpression(node, false)
    return {
      hasProperty: (name, option) => base.hasProperty(name, option),
      allProperties: () => base.allProperties(),
      getNest: (name) => base.getNest(name).getNest('value'),
      getNestNodes: (name) => base.getNest(name).getNestNodes('value')
    }
  }

  /**
   * Extract the property references from VExpressionContainer.
   * @param {VExpressionContainer} node
   * @param {object} [options]
   * @param {boolean} [options.ignoreGlobals]
   * @returns {IPropertyReferences}
   */
  function extractFromVExpressionContainer(node, options) {
    const ignoreGlobals = options && options.ignoreGlobals

    /** @type { (name:string)=>boolean } */
    let ignoreRef = () => false
    if (ignoreGlobals) {
      const globalScope =
        context.getSourceCode().scopeManager.globalScope ||
        context.getSourceCode().scopeManager.scopes[0]

      ignoreRef = (name) => globalScope.set.has(name)
    }
    /** @type {IPropertyReferences[]} */
    const references = []
    for (const id of node.references
      .filter((ref) => ref.variable == null)
      .map((ref) => ref.id)) {
      if (ignoreRef(id.name)) {
        continue
      }
      if (isFunctionalTemplate) {
        if (id.name === 'props') {
          references.push(extractFromExpression(id, true))
        }
      } else {
        const referenceId =
          id.name === '$props' &&
          id.parent.type === 'MemberExpression' &&
          id.parent.property.type === 'Identifier'
            ? id.parent.property
            : id

        references.push(
          extractFromName(referenceId.name, referenceId, () =>
            extractFromExpression(referenceId, true)
          )
        )
      }
    }
    return mergePropertyReferences(references)
  }
  /**
   * Extract the property references from StyleVariablesContext.
   * @param {StyleVariablesContext} ctx
   * @returns {IPropertyReferences}
   */
  function extractFromStyleVariablesContext(ctx) {
    const references = []
    for (const { id } of ctx.references) {
      references.push(
        extractFromName(id.name, id, () => extractFromExpression(id, true))
      )
    }
    return mergePropertyReferences(references)
  }

  return {
    extractFromExpression,
    extractFromPattern,
    extractFromFunctionParam,
    extractFromPath,
    extractFromName,
    extractFromNameLiteral,
    extractFromVExpressionContainer,
    extractFromStyleVariablesContext
  }
}

/**
 * @param {IPropertyReferences[]} references
 * @returns {IPropertyReferences}
 */
function mergePropertyReferences(references) {
  if (references.length === 0) {
    return NEVER
  }
  if (references.length === 1) {
    return references[0]
  }
  return new PropertyReferencesForMerge(references)
}

/**
 * Collects the property references for merge.
 * @implements {IPropertyReferences}
 */
class PropertyReferencesForMerge {
  /**
   * @param {IPropertyReferences[]} references
   */
  constructor(references) {
    this.references = references
  }

  /**
   * @param {string} name
   * @param {IHasPropertyOption} [option]
   */
  hasProperty(name, option) {
    return this.references.some((ref) => ref.hasProperty(name, option))
  }

  allProperties() {
    const result = new Map()
    for (const reference of this.references) {
      for (const [name, { nodes }] of reference.allProperties()) {
        const r = result.get(name)
        if (r) {
          r.nodes = [...new Set([...r.nodes, ...nodes])]
        } else {
          result.set(name, { nodes: [...nodes] })
        }
      }
    }
    return result
  }

  /**
   * @param {string} name
   * @returns {IPropertyReferences}
   */
  getNest(name) {
    /** @type {IPropertyReferences[]} */
    const nest = []
    for (const ref of this.references) {
      if (ref.hasProperty(name)) {
        nest.push(ref.getNest(name))
      }
    }
    return mergePropertyReferences(nest)
  }

  /**
   * @param {string} name
   * @returns {Iterable<NestPropertyNode>}
   */
  *getNestNodes(name) {
    for (const ref of this.references) {
      if (ref.hasProperty(name)) {
        yield* ref.getNestNodes(name)
      }
    }
  }
}
