/**
 * @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 Components = require('../util/Components');
const docsUrl = require('../util/docsUrl');

// 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;
  } else if (type === 'Literal') {
    return String(node.value);
  } else if (type === 'TemplateLiteral' && node.expressions.length === 0) {
    return node.quasis[0].value.raw;
  }
  return null;
}

function isThisExpression(node) {
  return 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) {
  return (
    node.callee.type === 'MemberExpression' &&
    isThisExpression(node.callee.object) &&
    getName(node.callee.property) === 'setState'
  );
}

module.exports = {
  meta: {
    docs: {
      description: 'Prevent definition of unused state fields',
      category: 'Best Practices',
      recommended: false,
      url: docsUrl('no-unused-state')
    },
    schema: []
  },

  create: Components.detect((context, components, utils) => {
    // 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 = context.getScope();
      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) {
      for (const prop of node.properties) {
        const key = prop.key;

        if (
          prop.type === 'Property' &&
          (key.type === 'Literal' ||
          (key.type === 'TemplateLiteral' && key.expressions.length === 0) ||
          (prop.computed === false && key.type === 'Identifier')) &&
          getName(prop.key) !== null
        ) {
          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) {
      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) {
      switch (left.type) {
        case 'Identifier':
          if (isStateReference(right) && classInfo.aliases) {
            classInfo.aliases.add(left.name);
          }
          break;
        case 'ObjectPattern':
          if (isStateReference(right)) {
            handleStateDestructuring(left);
          } else if (isThisExpression(right) && 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)) {
          context.report(node, `Unused state field: '${name}'`);
        }
      }
    }

    return {
      ClassDeclaration(node) {
        if (utils.isES6Component(node)) {
          classInfo = getInitialClassInfo();
        }
      },

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

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

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

      'ClassDeclaration:exit'() {
        if (!classInfo) {
          return;
        }
        reportUnusedFields();
        classInfo = null;
      },

      CallExpression(node) {
        if (!classInfo) {
          return;
        }
        // If we're looking at a `this.setState({})` invocation, record all the
        // properties as state fields.
        if (
          isSetStateCall(node) &&
          node.arguments.length > 0 &&
          node.arguments[0].type === 'ObjectExpression'
        ) {
          addStateFields(node.arguments[0]);
        } else if (
          isSetStateCall(node) &&
          node.arguments.length > 0 &&
          node.arguments[0].type === 'ArrowFunctionExpression'
        ) {
          if (node.arguments[0].body.type === 'ObjectExpression') {
            addStateFields(node.arguments[0].body);
          }
          if (node.arguments[0].params.length > 0 && classInfo.aliases) {
            const firstParam = node.arguments[0].params[0];
            if (firstParam.type === 'ObjectPattern') {
              handleStateDestructuring(firstParam);
            } else {
              classInfo.aliases.add(getName(firstParam));
            }
          }
        }
      },

      ClassProperty(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.
        if (
          getName(node.key) === 'state' &&
          !node.static &&
          node.value &&
          node.value.type === 'ObjectExpression'
        ) {
          addStateFields(node.value);
        }

        if (
          !node.static &&
          node.value &&
          node.value.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;
        }
      },

      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 (!utils.isES5Component(parent.parent)) {
          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;
        }
        // Check for assignments like `this.state = {}`
        if (
          node.left.type === 'MemberExpression' &&
          isThisExpression(node.left.object) &&
          getName(node.left.property) === 'state' &&
          node.right.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(node.right);
          }
        } else {
          // Check for assignments like `alias = this.state` and record the alias.
          handleAssignment(node.left, node.right);
        }
      },

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

      MemberExpression(node) {
        if (!classInfo) {
          return;
        }
        if (isStateReference(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;
        }
      }
    };
  })
};
