/**
 * @fileoverview  Attempts to discover all state fields in a React component and
 * warn if any of them are never read.
 *
 * State field definitions are collected from `this.state = {}` assignments in
 * the constructor, objects passed to `this.setState()`, and `state = {}` class
 * property assignments.
 */

'use strict';

const docsUrl = require('../util/docsUrl');
const ast = require('../util/ast');
const componentUtil = require('../util/componentUtil');
const report = require('../util/report');
const getScope = require('../util/eslint').getScope;

// Descend through all wrapping TypeCastExpressions and return the expression
// that was cast.
function uncast(node) {
  while (node.type === 'TypeCastExpression') {
    node = node.expression;
  }
  return node;
}

// Return the name of an identifier or the string value of a literal. Useful
// anywhere that a literal may be used as a key (e.g., member expressions,
// method definitions, ObjectExpression property keys).
function getName(node) {
  node = uncast(node);
  const type = node.type;

  if (type === 'Identifier') {
    return node.name;
  }
  if (type === 'Literal') {
    return String(node.value);
  }
  if (type === 'TemplateLiteral' && node.expressions.length === 0) {
    return node.quasis[0].value.raw;
  }
  return null;
}

function isThisExpression(node) {
  return ast.unwrapTSAsExpression(uncast(node)).type === 'ThisExpression';
}

function getInitialClassInfo() {
  return {
    // Set of nodes where state fields were defined.
    stateFields: new Set(),

    // Set of names of state fields that we've seen used.
    usedStateFields: new Set(),

    // Names of local variables that may be pointing to this.state. To
    // track this properly, we would need to keep track of all locals,
    // shadowing, assignments, etc. To keep things simple, we only
    // maintain one set of aliases per method and accept that it will
    // produce some false negatives.
    aliases: null,
  };
}

function isSetStateCall(node) {
  const unwrappedCalleeNode = ast.unwrapTSAsExpression(node.callee);

  return (
    unwrappedCalleeNode.type === 'MemberExpression'
    && isThisExpression(unwrappedCalleeNode.object)
    && getName(unwrappedCalleeNode.property) === 'setState'
  );
}

const messages = {
  unusedStateField: 'Unused state field: \'{{name}}\'',
};

module.exports = {
  meta: {
    docs: {
      description: 'Disallow definitions of unused state',
      category: 'Best Practices',
      recommended: false,
      url: docsUrl('no-unused-state'),
    },

    messages,

    schema: [],
  },

  create(context) {
    // Non-null when we are inside a React component ClassDeclaration and we have
    // not yet encountered any use of this.state which we have chosen not to
    // analyze. If we encounter any such usage (like this.state being spread as
    // JSX attributes), then this is again set to null.
    let classInfo = null;

    function isStateParameterReference(node) {
      const classMethods = [
        'shouldComponentUpdate',
        'componentWillUpdate',
        'UNSAFE_componentWillUpdate',
        'getSnapshotBeforeUpdate',
        'componentDidUpdate',
      ];

      let scope = getScope(context, node);
      while (scope) {
        const parent = scope.block && scope.block.parent;
        if (
          parent
          && parent.type === 'MethodDefinition' && (
            (parent.static && parent.key.name === 'getDerivedStateFromProps')
            || classMethods.indexOf(parent.key.name) !== -1
          )
          && parent.value.type === 'FunctionExpression'
          && parent.value.params[1]
          && parent.value.params[1].name === node.name
        ) {
          return true;
        }
        scope = scope.upper;
      }

      return false;
    }

    // Returns true if the given node is possibly a reference to `this.state` or the state parameter of
    // a lifecycle method.
    function isStateReference(node) {
      node = uncast(node);

      const isDirectStateReference = node.type === 'MemberExpression'
        && isThisExpression(node.object)
        && node.property.name === 'state';

      const isAliasedStateReference = node.type === 'Identifier'
        && classInfo.aliases
        && classInfo.aliases.has(node.name);

      return isDirectStateReference || isAliasedStateReference || isStateParameterReference(node);
    }

    // Takes an ObjectExpression node and adds all named Property nodes to the
    // current set of state fields.
    function addStateFields(node) {
      node.properties.filter((prop) => (
        prop.type === 'Property'
          && (prop.key.type === 'Literal'
          || (prop.key.type === 'TemplateLiteral' && prop.key.expressions.length === 0)
          || (prop.computed === false && prop.key.type === 'Identifier'))
          && getName(prop.key) !== null
      )).forEach((prop) => {
        classInfo.stateFields.add(prop);
      });
    }

    // Adds the name of the given node as a used state field if the node is an
    // Identifier or a Literal. Other node types are ignored.
    function addUsedStateField(node) {
      if (!classInfo) {
        return;
      }
      const name = getName(node);
      if (name) {
        classInfo.usedStateFields.add(name);
      }
    }

    // Records used state fields and new aliases for an ObjectPattern which
    // destructures `this.state`.
    function handleStateDestructuring(node) {
      for (const prop of node.properties) {
        if (prop.type === 'Property') {
          addUsedStateField(prop.key);
        } else if (
          (prop.type === 'ExperimentalRestProperty' || prop.type === 'RestElement')
          && classInfo.aliases
        ) {
          classInfo.aliases.add(getName(prop.argument));
        }
      }
    }

    // Used to record used state fields and new aliases for both
    // AssignmentExpressions and VariableDeclarators.
    function handleAssignment(left, right) {
      const unwrappedRight = ast.unwrapTSAsExpression(right);

      switch (left.type) {
        case 'Identifier':
          if (isStateReference(unwrappedRight) && classInfo.aliases) {
            classInfo.aliases.add(left.name);
          }
          break;
        case 'ObjectPattern':
          if (isStateReference(unwrappedRight)) {
            handleStateDestructuring(left);
          } else if (isThisExpression(unwrappedRight) && classInfo.aliases) {
            for (const prop of left.properties) {
              if (prop.type === 'Property' && getName(prop.key) === 'state') {
                const name = getName(prop.value);
                if (name) {
                  classInfo.aliases.add(name);
                } else if (prop.value.type === 'ObjectPattern') {
                  handleStateDestructuring(prop.value);
                }
              }
            }
          }
          break;
        default:
        // pass
      }
    }

    function reportUnusedFields() {
      // Report all unused state fields.
      for (const node of classInfo.stateFields) {
        const name = getName(node.key);
        if (!classInfo.usedStateFields.has(name)) {
          report(context, messages.unusedStateField, 'unusedStateField', {
            node,
            data: {
              name,
            },
          });
        }
      }
    }

    function handleES6ComponentEnter(node) {
      if (componentUtil.isES6Component(node, context)) {
        classInfo = getInitialClassInfo();
      }
    }

    function handleES6ComponentExit() {
      if (!classInfo) {
        return;
      }
      reportUnusedFields();
      classInfo = null;
    }

    function isGDSFP(node) {
      const name = getName(node.key);
      if (
        !node.static
        || name !== 'getDerivedStateFromProps'
        || !node.value
        || !node.value.params
        || node.value.params.length < 2 // no `state` argument
      ) {
        return false;
      }
      return true;
    }

    return {
      ClassDeclaration: handleES6ComponentEnter,

      'ClassDeclaration:exit': handleES6ComponentExit,

      ClassExpression: handleES6ComponentEnter,

      'ClassExpression:exit': handleES6ComponentExit,

      ObjectExpression(node) {
        if (componentUtil.isES5Component(node, context)) {
          classInfo = getInitialClassInfo();
        }
      },

      'ObjectExpression:exit'(node) {
        if (!classInfo) {
          return;
        }

        if (componentUtil.isES5Component(node, context)) {
          reportUnusedFields();
          classInfo = null;
        }
      },

      CallExpression(node) {
        if (!classInfo) {
          return;
        }

        const unwrappedNode = ast.unwrapTSAsExpression(node);
        const unwrappedArgumentNode = ast.unwrapTSAsExpression(unwrappedNode.arguments[0]);

        // If we're looking at a `this.setState({})` invocation, record all the
        // properties as state fields.
        if (
          isSetStateCall(unwrappedNode)
          && unwrappedNode.arguments.length > 0
          && unwrappedArgumentNode.type === 'ObjectExpression'
        ) {
          addStateFields(unwrappedArgumentNode);
        } else if (
          isSetStateCall(unwrappedNode)
          && unwrappedNode.arguments.length > 0
          && unwrappedArgumentNode.type === 'ArrowFunctionExpression'
        ) {
          const unwrappedBodyNode = ast.unwrapTSAsExpression(unwrappedArgumentNode.body);

          if (unwrappedBodyNode.type === 'ObjectExpression') {
            addStateFields(unwrappedBodyNode);
          }
          if (unwrappedArgumentNode.params.length > 0 && classInfo.aliases) {
            const firstParam = unwrappedArgumentNode.params[0];
            if (firstParam.type === 'ObjectPattern') {
              handleStateDestructuring(firstParam);
            } else {
              classInfo.aliases.add(getName(firstParam));
            }
          }
        }
      },

      'ClassProperty, PropertyDefinition'(node) {
        if (!classInfo) {
          return;
        }
        // If we see state being assigned as a class property using an object
        // expression, record all the fields of that object as state fields.
        const unwrappedValueNode = ast.unwrapTSAsExpression(node.value);

        const name = getName(node.key);
        if (
          name === 'state'
          && !node.static
          && unwrappedValueNode
          && unwrappedValueNode.type === 'ObjectExpression'
        ) {
          addStateFields(unwrappedValueNode);
        }

        if (
          !node.static
          && unwrappedValueNode
          && unwrappedValueNode.type === 'ArrowFunctionExpression'
        ) {
          // Create a new set for this.state aliases local to this method.
          classInfo.aliases = new Set();
        }
      },

      'ClassProperty:exit'(node) {
        if (
          classInfo
          && !node.static
          && node.value
          && node.value.type === 'ArrowFunctionExpression'
        ) {
          // Forget our set of local aliases.
          classInfo.aliases = null;
        }
      },

      'PropertyDefinition, ClassProperty'(node) {
        if (!isGDSFP(node)) {
          return;
        }

        const childScope = getScope(context, node).childScopes.find((x) => x.block === node.value);
        if (!childScope) {
          return;
        }
        const scope = childScope.variableScope.childScopes.find((x) => x.block === node.value);
        const stateArg = node.value.params[1]; // probably "state"
        if (!scope || !scope.variables) {
          return;
        }
        const argVar = scope.variables.find((x) => x.name === stateArg.name);

        if (argVar) {
          const stateRefs = argVar.references;

          stateRefs.forEach((ref) => {
            const identifier = ref.identifier;
            if (identifier && identifier.parent && identifier.parent.type === 'MemberExpression') {
              addUsedStateField(identifier.parent.property);
            }
          });
        }
      },

      'PropertyDefinition:exit'(node) {
        if (
          classInfo
          && !node.static
          && node.value
          && node.value.type === 'ArrowFunctionExpression'
          && !isGDSFP(node)
        ) {
          // Forget our set of local aliases.
          classInfo.aliases = null;
        }
      },

      MethodDefinition() {
        if (!classInfo) {
          return;
        }
        // Create a new set for this.state aliases local to this method.
        classInfo.aliases = new Set();
      },

      'MethodDefinition:exit'() {
        if (!classInfo) {
          return;
        }
        // Forget our set of local aliases.
        classInfo.aliases = null;
      },

      FunctionExpression(node) {
        if (!classInfo) {
          return;
        }

        const parent = node.parent;
        if (!componentUtil.isES5Component(parent.parent, context)) {
          return;
        }

        if (parent.key.name === 'getInitialState') {
          const body = node.body.body;
          const lastBodyNode = body[body.length - 1];

          if (
            lastBodyNode.type === 'ReturnStatement'
            && lastBodyNode.argument.type === 'ObjectExpression'
          ) {
            addStateFields(lastBodyNode.argument);
          }
        } else {
          // Create a new set for this.state aliases local to this method.
          classInfo.aliases = new Set();
        }
      },

      AssignmentExpression(node) {
        if (!classInfo) {
          return;
        }

        const unwrappedLeft = ast.unwrapTSAsExpression(node.left);
        const unwrappedRight = ast.unwrapTSAsExpression(node.right);

        // Check for assignments like `this.state = {}`
        if (
          unwrappedLeft.type === 'MemberExpression'
          && isThisExpression(unwrappedLeft.object)
          && getName(unwrappedLeft.property) === 'state'
          && unwrappedRight.type === 'ObjectExpression'
        ) {
          // Find the nearest function expression containing this assignment.
          let fn = node;
          while (fn.type !== 'FunctionExpression' && fn.parent) {
            fn = fn.parent;
          }
          // If the nearest containing function is the constructor, then we want
          // to record all the assigned properties as state fields.
          if (
            fn.parent
            && fn.parent.type === 'MethodDefinition'
            && fn.parent.kind === 'constructor'
          ) {
            addStateFields(unwrappedRight);
          }
        } else {
          // Check for assignments like `alias = this.state` and record the alias.
          handleAssignment(unwrappedLeft, unwrappedRight);
        }
      },

      VariableDeclarator(node) {
        if (!classInfo || !node.init) {
          return;
        }
        handleAssignment(node.id, node.init);
      },

      'MemberExpression, OptionalMemberExpression'(node) {
        if (!classInfo) {
          return;
        }
        if (isStateReference(ast.unwrapTSAsExpression(node.object))) {
          // If we see this.state[foo] access, give up.
          if (node.computed && node.property.type !== 'Literal') {
            classInfo = null;
            return;
          }
          // Otherwise, record that we saw this property being accessed.
          addUsedStateField(node.property);
        // If we see a `this.state` access in a CallExpression, give up.
        } else if (isStateReference(node) && node.parent.type === 'CallExpression') {
          classInfo = null;
        }
      },

      JSXSpreadAttribute(node) {
        if (classInfo && isStateReference(node.argument)) {
          classInfo = null;
        }
      },

      'ExperimentalSpreadProperty, SpreadElement'(node) {
        if (classInfo && isStateReference(node.argument)) {
          classInfo = null;
        }
      },
    };
  },
};
