/**
 * File overview here, scroll to bottom.
 * @link https://github.com/solidjs-community/eslint-plugin-solid/blob/main/docs/reactivity.md
 */

import { TSESTree as T, TSESLint, ESLintUtils, ASTUtils } from "@typescript-eslint/utils";
import { traverse } from "estraverse";
import {
  findParent,
  findInScope,
  isPropsByName,
  FunctionNode,
  isFunctionNode,
  ProgramOrFunctionNode,
  isProgramOrFunctionNode,
  trackImports,
  isDOMElementName,
  ignoreTransparentWrappers,
  getFunctionName,
  isJSXElementOrFragment,
  trace,
} from "../utils";
import { findVariable, CompatContext, getSourceCode } from "../compat";

const { getFunctionHeadLocation } = ASTUtils;
const createRule = ESLintUtils.RuleCreator.withoutDocs;

type Variable = TSESLint.Scope.Variable;
type Reference = TSESLint.Scope.Reference;

interface ReactiveVariable {
  /**
   * The reactive variable references we're concerned with (i.e. not init).
   * References are removed after they are analyzed.
   */
  references: Array<Reference>;
  /**
   * The function node in which the reactive variable was declared, or for a
   * derived signal (function), the deepest function node that declares a
   * referenced signal.
   */
  declarationScope: ProgramOrFunctionNode;
  /**
   * The reactive variable. Not used directly, only needed for identification
   * in pushUniqueDerivedSignal.
   */
  variable: Variable;
}

interface TrackedScope {
  /**
   * The root node, usually a function or JSX expression container, to allow
   * reactive variables under.
   */
  node: T.Node;
  /**
   * The reactive variable should be one of these types:
   * - "function": synchronous function or signal variable
   * - "called-function": synchronous or asynchronous function like a timer or
   *   event handler that isn't really a tracked scope but allows reactivity
   * - "expression": some value containing reactivity somewhere
   */
  expect: "function" | "called-function" | "expression";
}

class ScopeStackItem {
  /** the node for the current scope, or program if global scope */
  node: ProgramOrFunctionNode;
  /**
   * nodes whose descendants in the current scope are allowed to be reactive.
   * JSXExpressionContainers can be any expression containing reactivity, while
   * function nodes/identifiers are typically arguments to solid-js primitives
   * and should match a tracked scope exactly.
   */
  trackedScopes: Array<TrackedScope> = [];
  /** nameless functions with reactivity, should exactly match a tracked scope */
  unnamedDerivedSignals = new Set<FunctionNode>();
  /** switched to true by time of :exit if JSX is detected in the current scope */
  hasJSX = false;

  constructor(node: ProgramOrFunctionNode) {
    this.node = node;
  }
}

class ScopeStack extends Array<ScopeStackItem> {
  currentScope = () => this[this.length - 1];
  parentScope = () => this[this.length - 2];

  /** Add references to a signal, memo, derived signal, etc. */
  pushSignal(
    variable: Variable,
    declarationScope: ProgramOrFunctionNode = this.currentScope().node
  ) {
    this.signals.push({
      references: variable.references.filter((reference) => !reference.init),
      variable,
      declarationScope,
    });
  }

  /**
   * Add references to a signal, merging with existing references if the
   * variable is the same. Derived signals are special; they don't use the
   * declaration scope of the function, but rather the minimum declaration scope
   * of any signals they contain.
   */
  pushUniqueSignal(variable: Variable, declarationScope: ProgramOrFunctionNode) {
    const foundSignal = this.signals.find((s) => s.variable === variable);
    if (!foundSignal) {
      this.pushSignal(variable, declarationScope);
    } else {
      foundSignal.declarationScope = this.findDeepestDeclarationScope(
        foundSignal.declarationScope,
        declarationScope
      );
    }
  }

  /** Add references to a props or store. */
  pushProps(
    variable: Variable,
    declarationScope: ProgramOrFunctionNode = this.currentScope().node
  ) {
    this.props.push({
      references: variable.references.filter((reference) => !reference.init),
      variable,
      declarationScope,
    });
  }

  /** Function callbacks that run synchronously and don't create a new scope. */
  syncCallbacks = new Set<FunctionNode>();

  /**
   * Iterate through and remove the signal references in the current scope.
   * That way, the next Scope up can safely check for references in its scope.
   */
  *consumeSignalReferencesInScope() {
    yield* this.consumeReferencesInScope(this.signals);
    this.signals = this.signals.filter((variable) => variable.references.length !== 0);
  }

  /** Iterate through and remove the props references in the current scope. */
  *consumePropsReferencesInScope() {
    yield* this.consumeReferencesInScope(this.props);
    this.props = this.props.filter((variable) => variable.references.length !== 0);
  }

  private *consumeReferencesInScope(
    variables: Array<ReactiveVariable>
  ): Iterable<{ reference: Reference; declarationScope: ProgramOrFunctionNode }> {
    for (const variable of variables) {
      const { references } = variable;
      const inScope: Array<Reference> = [],
        notInScope: Array<Reference> = [];
      references.forEach((reference) => {
        if (this.isReferenceInCurrentScope(reference)) {
          inScope.push(reference);
        } else {
          notInScope.push(reference);
        }
      });
      yield* inScope.map((reference) => ({
        reference,
        declarationScope: variable.declarationScope,
      }));
      // I don't think this is needed! Just a perf optimization
      variable.references = notInScope;
    }
  }

  /** Returns the function node deepest in the tree. Assumes a === b, a is inside b, or b is inside a. */
  private findDeepestDeclarationScope = (
    a: ProgramOrFunctionNode,
    b: ProgramOrFunctionNode
  ): ProgramOrFunctionNode => {
    if (a === b) return a;
    for (let i = this.length - 1; i >= 0; i -= 1) {
      const { node } = this[i];
      if (a === node || b === node) {
        return node;
      }
    }
    throw new Error("This should never happen");
  };

  /**
   * Returns true if the reference is in the current scope, handling sync
   * callbacks. Must be called on the :exit pass only.
   */
  private isReferenceInCurrentScope(reference: Reference) {
    let parentFunction = findParent(reference.identifier, isProgramOrFunctionNode);
    while (isFunctionNode(parentFunction) && this.syncCallbacks.has(parentFunction)) {
      parentFunction = findParent(parentFunction, isProgramOrFunctionNode);
    }
    return parentFunction === this.currentScope().node;
  }

  /** variable references to be treated as signals, memos, derived signals, etc. */
  private signals: Array<ReactiveVariable> = [];
  /** variables references to be treated as props (or stores) */
  private props: Array<ReactiveVariable> = [];
}

const getNthDestructuredVar = (id: T.Node, n: number, context: CompatContext): Variable | null => {
  if (id?.type === "ArrayPattern") {
    const el = id.elements[n];
    if (el?.type === "Identifier") {
      return findVariable(context, el);
    }
  }
  return null;
};

const getReturnedVar = (id: T.Node, context: CompatContext): Variable | null => {
  if (id.type === "Identifier") {
    return findVariable(context, id);
  }
  return null;
};

type MessageIds =
  | "noWrite"
  | "untrackedReactive"
  | "expectedFunctionGotExpression"
  | "badSignal"
  | "badUnnamedDerivedSignal"
  | "shouldDestructure"
  | "shouldAssign"
  | "noAsyncTrackedScope";
type Options = [{ customReactiveFunctions: string[] }];

export default createRule<Options, MessageIds>({
  meta: {
    type: "problem",
    docs: {
      description:
        "Enforce that reactivity (props, signals, memos, etc.) is properly used, so changes in those values will be tracked and update the view as expected.",
      url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/reactivity.md",
    },
    schema: [
      {
        type: "object",
        properties: {
          customReactiveFunctions: {
            description:
              "List of function names to consider as reactive functions (allow signals to be safely passed as arguments). In addition, any create* or use* functions are automatically included.",
            type: "array",
            items: {
              type: "string",
            },
            default: [],
          },
        },
        additionalProperties: false,
      },
    ],
    messages: {
      noWrite: "The reactive variable '{{name}}' should not be reassigned or altered directly.",
      untrackedReactive:
        "The reactive variable '{{name}}' should be used within JSX, a tracked scope (like createEffect), or inside an event handler function, or else changes will be ignored.",
      expectedFunctionGotExpression:
        "The reactive variable '{{name}}' should be wrapped in a function for reactivity. This includes event handler bindings on native elements, which are not reactive like other JSX props.",
      badSignal:
        "The reactive variable '{{name}}' should be called as a function when used in {{where}}.",
      badUnnamedDerivedSignal:
        "This function should be passed to a tracked scope (like createEffect) or an event handler because it contains reactivity, or else changes will be ignored.",
      shouldDestructure:
        "For proper analysis, array destructuring should be used to capture the {{nth}}result of this function call.",
      shouldAssign:
        "For proper analysis, a variable should be used to capture the result of this function call.",
      noAsyncTrackedScope:
        "This tracked scope should not be async. Solid's reactivity only tracks synchronously.",
    },
  },
  defaultOptions: [
    {
      customReactiveFunctions: [],
    },
  ],
  create(context, [options]) {
    const warnShouldDestructure = (node: T.Node, nth?: string) =>
      context.report({
        node,
        messageId: "shouldDestructure",
        data: nth ? { nth: nth + " " } : undefined,
      });
    const warnShouldAssign = (node: T.Node) => context.report({ node, messageId: "shouldAssign" });

    const sourceCode = getSourceCode(context);

    /** Represents the lexical function stack and relevant information for each function */
    const scopeStack = new ScopeStack();
    const { currentScope, parentScope } = scopeStack;

    /** Tracks imports from 'solid-js', handling aliases. */
    const { matchImport, handleImportDeclaration } = trackImports();

    /** Workaround for #61 */
    const markPropsOnCondition = (node: FunctionNode, cb: (props: T.Identifier) => boolean) => {
      if (
        node.params.length === 1 &&
        node.params[0].type === "Identifier" &&
        node.parent?.type !== "JSXExpressionContainer" && // "render props" aren't components
        node.parent?.type !== "TemplateLiteral" && // inline functions in tagged template literals aren't components
        cb(node.params[0])
      ) {
        // This function is a component, consider its parameter a props
        const propsParam = findVariable(context, node.params[0]);
        if (propsParam) {
          scopeStack.pushProps(propsParam, node);
        }
      }
    };

    /** Populates the function stack. */
    const onFunctionEnter = (node: ProgramOrFunctionNode) => {
      if (isFunctionNode(node)) {
        if (scopeStack.syncCallbacks.has(node)) {
          // Ignore sync callbacks like Array#forEach and certain Solid primitives
          return;
        }
        markPropsOnCondition(node, (props) => isPropsByName(props.name));
      }
      scopeStack.push(new ScopeStackItem(node));
    };

    /** Returns whether a node falls under a tracked scope in the current function scope */
    const matchTrackedScope = (trackedScope: TrackedScope, node: T.Node): boolean => {
      switch (trackedScope.expect) {
        case "function":
        case "called-function":
          return node === trackedScope.node;
        case "expression":
          return Boolean(
            findInScope(node, currentScope().node, (node) => node === trackedScope.node)
          );
      }
    };

    /** Inspects a specific reference of a reactive variable for correct handling. */
    const handleTrackedScopes = (
      identifier: T.Identifier,
      declarationScope: ProgramOrFunctionNode
    ) => {
      const currentScopeNode = currentScope().node;
      // Check if the call falls outside any tracked scopes in the current scope
      if (
        !currentScope().trackedScopes.find((trackedScope) =>
          matchTrackedScope(trackedScope, identifier)
        )
      ) {
        const matchedExpression = currentScope().trackedScopes.find((trackedScope) =>
          matchTrackedScope({ ...trackedScope, expect: "expression" }, identifier)
        );
        if (declarationScope === currentScopeNode) {
          // If the reactivity is not contained in a tracked scope, and any of
          // the reactive variables were declared in the current scope, then we
          // report them. When the reference is to an object in a
          // MemberExpression (props/store) or a function call (signal), report
          // that, otherwise the identifier.
          let parentMemberExpression: T.MemberExpression | null = null;
          if (identifier.parent?.type === "MemberExpression") {
            parentMemberExpression = identifier.parent;
            while (parentMemberExpression!.parent?.type === "MemberExpression") {
              parentMemberExpression = parentMemberExpression!.parent;
            }
          }
          const parentCallExpression =
            identifier.parent?.type === "CallExpression" ? identifier.parent : null;
          context.report({
            node: parentMemberExpression ?? parentCallExpression ?? identifier,
            messageId: matchedExpression ? "expectedFunctionGotExpression" : "untrackedReactive",
            data: {
              name: parentMemberExpression
                ? sourceCode.getText(parentMemberExpression)
                : identifier.name,
            },
          });
        } else {
          // If all of the reactive variables were declared above the current
          // function scope, then the entire function becomes reactive with the
          // deepest declaration scope of the reactive variables it contains.
          // Let the next onFunctionExit up handle it.
          if (!parentScope() || !isFunctionNode(currentScopeNode)) {
            throw new Error("this shouldn't happen!");
          }

          // If the current function doesn't have an associated variable, that's
          // fine, it's being used inline (i.e. anonymous arrow function). For
          // this to be okay, the arrow function has to be the same node as one
          // of the tracked scopes, as we can't easily find references.
          const pushUnnamedDerivedSignal = () =>
            (parentScope().unnamedDerivedSignals ??= new Set()).add(currentScopeNode);

          if (currentScopeNode.type === "FunctionDeclaration") {
            // get variable representing function, function node only defines one variable
            const functionVariable: Variable | undefined =
              sourceCode.scopeManager?.getDeclaredVariables(currentScopeNode)?.[0];
            if (functionVariable) {
              scopeStack.pushUniqueSignal(
                functionVariable,
                declarationScope // use declaration scope of a signal contained in this function
              );
            } else {
              pushUnnamedDerivedSignal();
            }
          } else if (currentScopeNode.parent?.type === "VariableDeclarator") {
            const declarator = currentScopeNode.parent;
            // for nameless or arrow function expressions, use the declared variable it's assigned to
            const functionVariable = sourceCode.scopeManager?.getDeclaredVariables(declarator)?.[0];
            if (functionVariable) {
              // use declaration scope of a signal contained in this scope, not the function itself
              scopeStack.pushUniqueSignal(functionVariable, declarationScope);
            } else {
              pushUnnamedDerivedSignal();
            }
          } else if (currentScopeNode.parent?.type === "Property") {
            // todo make this a unique props or something--for now, just ignore (unsafe)
          } else {
            pushUnnamedDerivedSignal();
          }
        }
      }
    };

    /** Performs all analysis and reporting. */
    const onFunctionExit = (currentScopeNode: ProgramOrFunctionNode) => {
      // If this function is a component, add its props as a reactive variable
      if (isFunctionNode(currentScopeNode)) {
        markPropsOnCondition(currentScopeNode, (props) => {
          if (
            !isPropsByName(props.name) && // already added in markPropsOnEnter
            currentScope().hasJSX
          ) {
            const functionName = getFunctionName(currentScopeNode);
            // begins with lowercase === not component
            if (functionName && !/^[a-z]/.test(functionName)) return true;
          }
          return false;
        });
      }

      // Ignore sync callbacks like Array#forEach and certain Solid primitives.
      // In this case only, currentScopeNode !== currentScope().node, but we're
      // returning early so it doesn't matter.
      if (isFunctionNode(currentScopeNode) && scopeStack.syncCallbacks.has(currentScopeNode)) {
        return;
      }

      // Iterate through all usages of (derived) signals in the current scope
      for (const { reference, declarationScope } of scopeStack.consumeSignalReferencesInScope()) {
        const identifier = reference.identifier;
        if (reference.isWrite()) {
          // don't allow reassigning signals
          context.report({
            node: identifier,
            messageId: "noWrite",
            data: {
              name: identifier.name,
            },
          });
        } else if (identifier.type === "Identifier") {
          const reportBadSignal = (where: string) =>
            context.report({
              node: identifier,
              messageId: "badSignal",
              data: { name: identifier.name, where },
            });
          if (
            // This allows both calling a signal and calling a function with a signal.
            identifier.parent?.type === "CallExpression" ||
            // Also allow the case where we pass an array of signals, such as in a custom hook
            (identifier.parent?.type === "ArrayExpression" &&
              identifier.parent.parent?.type === "CallExpression")
          ) {
            // This signal is getting called properly, analyze it.
            handleTrackedScopes(identifier, declarationScope);
          } else if (identifier.parent?.type === "TemplateLiteral") {
            reportBadSignal("template literals");
          } else if (
            identifier.parent?.type === "BinaryExpression" &&
            [
              "<",
              "<=",
              ">",
              ">=",
              "<<",
              ">>",
              ">>>",
              "+",
              "-",
              "*",
              "/",
              "%",
              "**",
              "|",
              "^",
              "&",
              "in",
            ].includes(identifier.parent.operator)
          ) {
            // We're in an arithmetic/comparison expression where using an uncalled signal wouldn't make sense
            reportBadSignal("arithmetic or comparisons");
          } else if (
            identifier.parent?.type === "UnaryExpression" &&
            ["-", "+", "~"].includes(identifier.parent.operator)
          ) {
            // We're in a unary expression where using an uncalled signal wouldn't make sense
            reportBadSignal("unary expressions");
          } else if (
            identifier.parent?.type === "MemberExpression" &&
            identifier.parent.computed &&
            identifier.parent.property === identifier
          ) {
            // We're using an uncalled signal to index an object or array, which doesn't make sense
            reportBadSignal("property accesses");
          } else if (
            identifier.parent?.type === "JSXExpressionContainer" &&
            !currentScope().trackedScopes.find(
              (trackedScope) =>
                trackedScope.node === identifier &&
                (trackedScope.expect === "function" || trackedScope.expect === "called-function")
            )
          ) {
            // If the signal is in a JSXExpressionContainer that's also marked as a "function" or "called-function" tracked scope,
            // let it be.
            const elementOrAttribute = identifier.parent.parent;
            if (
              // The signal is not being called and is being used as a props.children, where calling
              // the signal was the likely intent.
              isJSXElementOrFragment(elementOrAttribute) ||
              // We can't say for sure about user components, but we know for a fact that a signal
              // should not be passed to a non-event handler DOM element attribute without calling it.
              (elementOrAttribute?.type === "JSXAttribute" &&
                elementOrAttribute.parent?.type === "JSXOpeningElement" &&
                elementOrAttribute.parent.name.type === "JSXIdentifier" &&
                isDOMElementName(elementOrAttribute.parent.name.name))
            ) {
              reportBadSignal("JSX");
            }
          }
        }
        // The signal is being read outside of a CallExpression. Since
        // there's a lot of possibilities here and they're generally fine,
        // do nothing.
      }

      // Do a similar thing with all usages of props in the current function
      for (const { reference, declarationScope } of scopeStack.consumePropsReferencesInScope()) {
        const identifier = reference.identifier;
        if (reference.isWrite()) {
          // don't allow reassigning props or stores
          context.report({
            node: identifier,
            messageId: "noWrite",
            data: {
              name: identifier.name,
            },
          });
        } else if (
          identifier.parent?.type === "MemberExpression" &&
          identifier.parent.object === identifier
        ) {
          const { parent } = identifier;
          if (parent.parent?.type === "AssignmentExpression" && parent.parent.left === parent) {
            // don't allow writing to props or stores directly
            context.report({
              node: identifier,
              messageId: "noWrite",
              data: {
                name: identifier.name,
              },
            });
          } else if (
            parent.property.type === "Identifier" &&
            /^(?:initial|default|static[A-Z])/.test(parent.property.name)
          ) {
            // We're using a prop with a name that starts with `initial` or
            // `default`, like `props.initialCount`. We'll refrain from warning
            // about untracked usages of these props, because the user has shown
            // that they understand the consequences of using a reactive
            // variable to initialize something else. Do nothing.
          } else {
            // The props are the object in a property read access, which
            // should be under a tracked scope.
            handleTrackedScopes(identifier, declarationScope);
          }
        } else if (
          identifier.parent?.type === "AssignmentExpression" ||
          identifier.parent?.type === "VariableDeclarator"
        ) {
          // There's no reason to allow `... = props`, it's usually destructuring, which breaks reactivity.
          context.report({
            node: identifier,
            messageId: "untrackedReactive",
            data: { name: identifier.name },
          });
        }
        // The props are being read, but not in a MemberExpression. Since
        // there's a lot of possibilities here and they're generally fine,
        // do nothing.
      }

      // If there are any unnamed derived signals, they must match a tracked
      // scope. Usually anonymous arrow function args to createEffect,
      // createMemo, etc.
      const { unnamedDerivedSignals } = currentScope();
      if (unnamedDerivedSignals) {
        for (const node of unnamedDerivedSignals) {
          if (
            !currentScope().trackedScopes.find((trackedScope) =>
              matchTrackedScope(trackedScope, node)
            )
          ) {
            context.report({
              loc: getFunctionHeadLocation(node, sourceCode),
              messageId: "badUnnamedDerivedSignal",
            });
          }
        }
      }

      // Pop on exit
      scopeStack.pop();
    };

    /*
     * Sync array functions (forEach, map, reduce, reduceRight, flatMap),
     * store update fn params (ex. setState("todos", (t) => [...t.slice(0, i()),
     * ...t.slice(i() + 1)])), batch, onCleanup, and onError fn params, and
     * maybe a few others don't actually create a new scope. That is, any
     * signal/prop accesses in these functions act as if they happen in the
     * enclosing function. Note that this means whether or not the enclosing
     * function is a tracking scope applies to the fn param as well.
     *
     * Every time a sync callback is detected, we put that function node into a
     * syncCallbacks Set<FunctionNode>. The detections must happen on the entry pass
     * and when the function node has not yet been traversed. In onFunctionEnter, if
     * the function node is in syncCallbacks, we don't push it onto the
     * scopeStack. In onFunctionExit, if the function node is in syncCallbacks,
     * we don't pop scopeStack.
     */
    const checkForSyncCallbacks = (node: T.CallExpression) => {
      if (
        node.arguments.length === 1 &&
        isFunctionNode(node.arguments[0]) &&
        !node.arguments[0].async
      ) {
        if (
          node.callee.type === "Identifier" &&
          matchImport(["batch", "produce"], node.callee.name)
        ) {
          // These Solid APIs take callbacks that run in the current scope
          scopeStack.syncCallbacks.add(node.arguments[0]);
        } else if (
          node.callee.type === "MemberExpression" &&
          !node.callee.computed &&
          node.callee.object.type !== "ObjectExpression" &&
          /^(?:forEach|map|flatMap|reduce|reduceRight|find|findIndex|filter|every|some)$/.test(
            node.callee.property.name
          )
        ) {
          // These common array methods (or likely array methods) take synchronous callbacks
          scopeStack.syncCallbacks.add(node.arguments[0]);
        }
      }
      if (node.callee.type === "Identifier") {
        if (
          matchImport(["createSignal", "createStore"], node.callee.name) &&
          node.parent?.type === "VariableDeclarator"
        ) {
          // Allow using reactive variables in state setter if the current scope is tracked.
          // ex.  const [state, setState] = createStore({ ... });
          //      setState(() => ({ preferredName: state.firstName, lastName: "Milner" }));
          const setter = getNthDestructuredVar(node.parent.id, 1, context);
          if (setter) {
            for (const reference of setter.references) {
              const { identifier } = reference;
              if (
                !reference.init &&
                reference.isRead() &&
                identifier.parent?.type === "CallExpression"
              ) {
                for (const arg of identifier.parent.arguments) {
                  if (isFunctionNode(arg) && !arg.async) {
                    scopeStack.syncCallbacks.add(arg);
                  }
                }
              }
            }
          }
        } else if (matchImport(["mapArray", "indexArray"], node.callee.name)) {
          const arg1 = node.arguments[1];
          if (isFunctionNode(arg1)) {
            scopeStack.syncCallbacks.add(arg1);
          }
        }
      }
      // Handle IIFEs
      if (isFunctionNode(node.callee)) {
        scopeStack.syncCallbacks.add(node.callee);
      }
    };

    /** Checks VariableDeclarators, AssignmentExpressions, and CallExpressions for reactivity. */
    const checkForReactiveAssignment = (
      id: T.BindingName | T.AssignmentExpression["left"] | null,
      init: T.Node
    ) => {
      init = ignoreTransparentWrappers(init);

      // Mark return values of certain functions as reactive
      if (init.type === "CallExpression" && init.callee.type === "Identifier") {
        const { callee } = init;
        if (matchImport(["createSignal", "useTransition"], callee.name)) {
          const signal = id && getNthDestructuredVar(id, 0, context);
          if (signal) {
            scopeStack.pushSignal(signal, currentScope().node);
          } else {
            warnShouldDestructure(id ?? init, "first");
          }
        } else if (matchImport(["createMemo", "createSelector"], callee.name)) {
          const memo = id && getReturnedVar(id, context);
          // memos act like signals
          if (memo) {
            scopeStack.pushSignal(memo, currentScope().node);
          } else {
            warnShouldAssign(id ?? init);
          }
        } else if (matchImport("createStore", callee.name)) {
          const store = id && getNthDestructuredVar(id, 0, context);
          // stores act like props
          if (store) {
            scopeStack.pushProps(store, currentScope().node);
          } else {
            warnShouldDestructure(id ?? init, "first");
          }
        } else if (matchImport("mergeProps", callee.name)) {
          const merged = id && getReturnedVar(id, context);
          if (merged) {
            scopeStack.pushProps(merged, currentScope().node);
          } else {
            warnShouldAssign(id ?? init);
          }
        } else if (matchImport("splitProps", callee.name)) {
          // splitProps can return an unbounded array of props variables, though it's most often two
          if (id?.type === "ArrayPattern") {
            const vars = id.elements
              .map((_, i) => getNthDestructuredVar(id, i, context))
              .filter(Boolean) as Array<Variable>;
            if (vars.length === 0) {
              warnShouldDestructure(id);
            } else {
              vars.forEach((variable) => {
                scopeStack.pushProps(variable, currentScope().node);
              });
            }
          } else {
            // if it's returned as an array, treat that as a props object
            const vars = id && getReturnedVar(id, context);
            if (vars) {
              scopeStack.pushProps(vars, currentScope().node);
            }
          }
        } else if (matchImport("createResource", callee.name)) {
          // createResource return value has reactive .loading and .error
          const resourceReturn = id && getNthDestructuredVar(id, 0, context);
          if (resourceReturn) {
            scopeStack.pushProps(resourceReturn, currentScope().node);
          }
        } else if (matchImport("createMutable", callee.name)) {
          const mutable = id && getReturnedVar(id, context);
          if (mutable) {
            scopeStack.pushProps(mutable, currentScope().node);
          }
        } else if (matchImport("mapArray", callee.name)) {
          const arg1 = init.arguments[1];
          if (
            isFunctionNode(arg1) &&
            arg1.params.length >= 2 &&
            arg1.params[1].type === "Identifier"
          ) {
            const indexSignal = findVariable(context, arg1.params[1]);
            if (indexSignal) {
              scopeStack.pushSignal(indexSignal);
            }
          }
        } else if (matchImport("indexArray", callee.name)) {
          const arg1 = init.arguments[1];
          if (
            isFunctionNode(arg1) &&
            arg1.params.length >= 1 &&
            arg1.params[0].type === "Identifier"
          ) {
            const valueSignal = findVariable(context, arg1.params[0]);
            if (valueSignal) {
              scopeStack.pushSignal(valueSignal);
            }
          }
        }
      }
    };

    const checkForTrackedScopes = (
      node:
        | T.JSXExpressionContainer
        | T.JSXSpreadAttribute
        | T.CallExpression
        | T.VariableDeclarator
        | T.AssignmentExpression
        | T.TaggedTemplateExpression
        | T.NewExpression
    ) => {
      const pushTrackedScope = (node: T.Node, expect: TrackedScope["expect"]) => {
        currentScope().trackedScopes.push({ node, expect });
        if (expect !== "called-function" && isFunctionNode(node) && node.async) {
          // From the docs: "[Solid's] approach only tracks synchronously. If you
          // have a setTimeout or use an async function in your Effect the code
          // that executes async after the fact won't be tracked."
          context.report({
            node,
            messageId: "noAsyncTrackedScope",
          });
        }
      };
      // given some expression, mark any functions within it as tracking scopes, and do not traverse
      // those functions
      const permissivelyTrackNode = (node: T.Node) => {
        traverse(node as any, {
          enter(cn) {
            const childNode = cn as T.Node;
            const traced = trace(childNode, context);
            // when referencing a function or something that could be a derived signal, track it
            if (
              isFunctionNode(traced) ||
              (traced.type === "Identifier" &&
                traced.parent.type !== "MemberExpression" &&
                !(traced.parent.type === "CallExpression" && traced.parent.callee === traced))
            ) {
              pushTrackedScope(childNode, "called-function");
              this.skip(); // poor-man's `findInScope`: don't enter child scopes
            }
          },
          fallback: "iteration", // Don't crash when encounter unknown node.
        });
      };

      if (node.type === "JSXExpressionContainer") {
        if (
          node.parent?.type === "JSXAttribute" &&
          sourceCode.getText(node.parent.name).startsWith("on") &&
          node.parent.parent?.type === "JSXOpeningElement" &&
          node.parent.parent.name.type === "JSXIdentifier" &&
          isDOMElementName(node.parent.parent.name.name)
        ) {
          // Expect a function if the attribute is like onClick={}, onclick={}, on:click={}, or
          // custom events such as on-click={}.
          // From the docs:
          // Events are never rebound and the bindings are not reactive, as it is expensive to
          // attach and detach listeners. Since event handlers are called like any other function
          // each time an event fires, there is no need for reactivity; simply shortcut your handler
          // if desired.
          // What this means here is we actually do consider an event handler a tracked scope
          // expecting a function, i.e. it's okay to use changing props/signals in the body of the
          // function, even though the changes don't affect when the handler will run. This is what
          // "called-function" represents—not quite a tracked scope, but a place where it's okay to
          // read reactive values.
          pushTrackedScope(node.expression, "called-function");
        } else if (
          node.parent?.type === "JSXAttribute" &&
          node.parent.name.type === "JSXNamespacedName" &&
          node.parent.name.namespace.name === "use" &&
          isFunctionNode(node.expression)
        ) {
          // With a `use:` hook, assume that a function passed is a called function.
          pushTrackedScope(node.expression, "called-function");
        } else if (
          node.parent?.type === "JSXAttribute" &&
          node.parent.name.name === "value" &&
          node.parent.parent?.type === "JSXOpeningElement" &&
          ((node.parent.parent.name.type === "JSXIdentifier" &&
            node.parent.parent.name.name.endsWith("Provider")) ||
            (node.parent.parent.name.type === "JSXMemberExpression" &&
              node.parent.parent.name.property.name === "Provider"))
        ) {
          // From the docs: "The value passed to provider is passed to useContext as is. That means
          // wrapping as a reactive expression will not work. You should pass in Signals and Stores
          // directly instead of accessing them in the JSX."
          // For `<SomeContext.Provider value={}>` or `<SomeProvider value={}>`, do nothing, the
          // rule will warn later.
          // TODO: add some kind of "anti- tracked scope" that still warns but enhances the error
          // message if matched.
        } else if (
          node.parent?.type === "JSXAttribute" &&
          node.parent.name?.type === "JSXIdentifier" &&
          /^static[A-Z]/.test(node.parent.name.name) &&
          node.parent.parent?.type === "JSXOpeningElement" &&
          node.parent.parent.name.type === "JSXIdentifier" &&
          !isDOMElementName(node.parent.parent.name.name)
        ) {
          // A caller is passing a value to a prop prefixed with `static` in a component, i.e.
          // `<Box staticName={...} />`. Since we're considering these props as static in the component
          // we shouldn't allow passing reactive values to them, as this isn't just ignoring reactivity
          // like initial*/default*; this is disabling it altogether as a convention. Do nothing.
        } else if (
          node.parent?.type === "JSXAttribute" &&
          node.parent.name.name === "ref" &&
          isFunctionNode(node.expression)
        ) {
          // Callback/function refs are called when an element is created but before it is connected
          // to the DOM. This is semantically a "called function", so it's fine to read reactive
          // variables here.
          pushTrackedScope(node.expression, "called-function");
        } else if (isJSXElementOrFragment(node.parent) && isFunctionNode(node.expression)) {
          pushTrackedScope(node.expression, "function"); // functions inline in JSX containers will be tracked
        } else {
          pushTrackedScope(node.expression, "expression");
        }
      } else if (node.type === "JSXSpreadAttribute") {
        // allow <div {...props.nestedProps} />; {...props} is already ignored
        pushTrackedScope(node.argument, "expression");
      } else if (node.type === "NewExpression") {
        const {
          callee,
          arguments: { 0: arg0 },
        } = node;
        if (
          callee.type === "Identifier" &&
          arg0 &&
          // Observers from Standard Web APIs
          [
            "IntersectionObserver",
            "MutationObserver",
            "PerformanceObserver",
            "ReportingObserver",
            "ResizeObserver",
          ].includes(callee.name)
        ) {
          // Observers callbacks are NOT tracked scopes. However, they
          // don't need to react to updates to reactive variables; it's okay
          // to poll the current value. Consider them called-function tracked
          // scopes for our purposes.
          pushTrackedScope(arg0, "called-function");
        }
      } else if (node.type === "CallExpression") {
        if (node.callee.type === "Identifier") {
          const {
            callee,
            arguments: { 0: arg0, 1: arg1 },
          } = node;
          if (
            matchImport(
              [
                "createMemo",
                "children",
                "createEffect",
                "createRenderEffect",
                "createDeferred",
                "createComputed",
                "createSelector",
                "untrack",
                "mapArray",
                "indexArray",
                "observable",
              ],
              callee.name
            ) ||
            (matchImport("createResource", callee.name) && node.arguments.length >= 2)
          ) {
            // createEffect, createMemo, etc. fn arg, and createResource optional
            // `source` first argument may be a signal
            pushTrackedScope(arg0, "function");
          } else if (
            matchImport(["onMount", "onCleanup", "onError"], callee.name) ||
            [
              // Timers
              "setInterval",
              "setTimeout",
              "setImmediate",
              "requestAnimationFrame",
              "requestIdleCallback",
            ].includes(callee.name)
          ) {
            // on* and timers are NOT tracked scopes. However, they
            // don't need to react to updates to reactive variables; it's okay
            // to poll the current value. Consider them called-function tracked
            // scopes for our purposes.
            pushTrackedScope(arg0, "called-function");
          } else if (matchImport("on", callee.name)) {
            // on accepts a signal or an array of signals as its first argument,
            // and a tracking function as its second
            if (arg0) {
              if (arg0.type === "ArrayExpression") {
                arg0.elements.forEach((element) => {
                  if (element && element?.type !== "SpreadElement") {
                    pushTrackedScope(element, "function");
                  }
                });
              } else {
                pushTrackedScope(arg0, "function");
              }
            }
            if (arg1) {
              // Since dependencies are known, function can be async
              pushTrackedScope(arg1, "called-function");
            }
          } else if (matchImport("createStore", callee.name) && arg0?.type === "ObjectExpression") {
            for (const property of arg0.properties) {
              if (
                property.type === "Property" &&
                property.kind === "get" &&
                isFunctionNode(property.value)
              ) {
                pushTrackedScope(property.value, "function");
              }
            }
          } else if (matchImport("runWithOwner", callee.name)) {
            // runWithOwner(owner, fn) only creates a tracked scope if `owner =
            // getOwner()` runs in a tracked scope. If owner is a variable,
            // attempt to detect if it's a tracked scope or not, but if this
            // can't be done, assume it's a tracked scope.
            if (arg1) {
              let isTrackedScope = true;
              const owner = arg0.type === "Identifier" && findVariable(context, arg0);
              if (owner) {
                const decl = owner.defs[0];
                if (
                  decl &&
                  decl.node.type === "VariableDeclarator" &&
                  decl.node.init?.type === "CallExpression" &&
                  decl.node.init.callee.type === "Identifier" &&
                  matchImport("getOwner", decl.node.init.callee.name)
                ) {
                  // Check if the function in which getOwner() is called is a tracked scope. If the scopeStack
                  // has moved on from that scope already, assume it's tracked, since that's less intrusive.
                  const ownerFunction = findParent(decl.node, isProgramOrFunctionNode);
                  const scopeStackIndex = scopeStack.findIndex(
                    ({ node }) => ownerFunction === node
                  );
                  if (
                    (scopeStackIndex >= 1 &&
                      !scopeStack[scopeStackIndex - 1].trackedScopes.some(
                        (trackedScope) =>
                          trackedScope.expect === "function" && trackedScope.node === ownerFunction
                      )) ||
                    scopeStackIndex === 0
                  ) {
                    isTrackedScope = false;
                  }
                }
              }
              if (isTrackedScope) {
                pushTrackedScope(arg1, "function");
              }
            }
          } else if (
            /^(?:use|create)[A-Z]/.test(callee.name) ||
            options.customReactiveFunctions.includes(callee.name)
          ) {
            // Custom hooks parameters may or may not be tracking scopes, no way to know.
            // Assume all identifier/function arguments are tracked scopes, and use "called-function"
            // to allow async handlers (permissive). Assume non-resolvable args are reactive expressions.
            for (const arg of node.arguments) {
              permissivelyTrackNode(arg);
            }
          }
        } else if (node.callee.type === "MemberExpression") {
          const { property } = node.callee;
          if (
            property.type === "Identifier" &&
            property.name === "addEventListener" &&
            node.arguments.length >= 2
          ) {
            // Like `on*` event handlers, mark all `addEventListener` listeners as called functions.
            pushTrackedScope(node.arguments[1], "called-function");
          } else if (
            property.type === "Identifier" &&
            (/^(?:use|create)[A-Z]/.test(property.name) ||
              options.customReactiveFunctions.includes(property.name))
          ) {
            // Handle custom hook parameters for property access custom hooks
            for (const arg of node.arguments) {
              permissivelyTrackNode(arg);
            }
          }
        }
      } else if (node.type === "VariableDeclarator") {
        // Solid 1.3 createReactive (renamed createReaction?) returns a track
        // function, a tracked scope expecting a reactive function. All of the
        // track function's references where it's called push a tracked scope.
        if (node.init?.type === "CallExpression" && node.init.callee.type === "Identifier") {
          if (matchImport(["createReactive", "createReaction"], node.init.callee.name)) {
            const track = getReturnedVar(node.id, context);
            if (track) {
              for (const reference of track.references) {
                if (
                  !reference.init &&
                  reference.isReadOnly() &&
                  reference.identifier.parent?.type === "CallExpression" &&
                  reference.identifier.parent.callee === reference.identifier
                ) {
                  const arg0 = reference.identifier.parent.arguments[0];
                  if (arg0) {
                    pushTrackedScope(arg0, "function");
                  }
                }
              }
            }
            if (isFunctionNode(node.init.arguments[0])) {
              pushTrackedScope(node.init.arguments[0], "called-function");
            }
          }
        }
      } else if (node.type === "AssignmentExpression") {
        if (
          node.left.type === "MemberExpression" &&
          node.left.property.type === "Identifier" &&
          isFunctionNode(node.right) &&
          /^on[a-z]+$/.test(node.left.property.name)
        ) {
          // To allow (questionable) code like the following example:
          //     ref.oninput = () = {
          //       if (!errors[ref.name]) return;
          //       ...
          //     }
          // where event handlers are manually attached to refs, detect these
          // scenarios and mark the right hand sides as tracked scopes expecting
          // functions.
          pushTrackedScope(node.right, "called-function");
        }
      } else if (node.type === "TaggedTemplateExpression") {
        for (const expression of node.quasi.expressions) {
          if (isFunctionNode(expression)) {
            // ex. css`color: ${props => props.color}`. Use "called-function" to allow async handlers (permissive)
            pushTrackedScope(expression, "called-function");

            // exception case: add a reactive variable within checkForTrackedScopes when a param is props
            for (const param of expression.params) {
              if (param.type === "Identifier" && isPropsByName(param.name)) {
                const variable = findVariable(context, param);
                if (variable) scopeStack.pushProps(variable, currentScope().node);
              }
            }
          }
        }
      }
    };

    return {
      ImportDeclaration: handleImportDeclaration,
      JSXExpressionContainer(node: T.JSXExpressionContainer) {
        checkForTrackedScopes(node);
      },
      JSXSpreadAttribute(node: T.JSXSpreadAttribute) {
        checkForTrackedScopes(node);
      },
      CallExpression(node: T.CallExpression) {
        checkForTrackedScopes(node);
        checkForSyncCallbacks(node);

        // ensure calls to reactive primitives use the results.
        const parent = node.parent && ignoreTransparentWrappers(node.parent, true);
        if (parent?.type !== "AssignmentExpression" && parent?.type !== "VariableDeclarator") {
          checkForReactiveAssignment(null, node);
        }
      },
      NewExpression(node: T.NewExpression) {
        checkForTrackedScopes(node);
      },
      VariableDeclarator(node: T.VariableDeclarator) {
        if (node.init) {
          checkForReactiveAssignment(node.id, node.init);
          checkForTrackedScopes(node);
        }
      },
      AssignmentExpression(node: T.AssignmentExpression) {
        if (node.left.type !== "MemberExpression") {
          checkForReactiveAssignment(node.left, node.right);
        }
        checkForTrackedScopes(node);
      },
      TaggedTemplateExpression(node: T.TaggedTemplateExpression) {
        checkForTrackedScopes(node);
      },
      "JSXElement > JSXExpressionContainer > :function"(node: T.Node) {
        if (
          isFunctionNode(node) &&
          node.parent?.type === "JSXExpressionContainer" &&
          node.parent.parent?.type === "JSXElement"
        ) {
          const element = node.parent.parent;

          if (element.openingElement.name.type === "JSXIdentifier") {
            const tagName = element.openingElement.name.name;
            if (
              matchImport("For", tagName) &&
              node.params.length === 2 &&
              node.params[1].type === "Identifier"
            ) {
              // Mark `index` in `<For>{(item, index) => <div /></For>` as a signal
              const index = findVariable(context, node.params[1]);
              if (index) {
                scopeStack.pushSignal(index, currentScope().node);
              }
            } else if (
              matchImport("Index", tagName) &&
              node.params.length >= 1 &&
              node.params[0].type === "Identifier"
            ) {
              // Mark `item` in `<Index>{(item, index) => <div />}</Index>` as a signal
              const item = findVariable(context, node.params[0]);
              if (item) {
                scopeStack.pushSignal(item, currentScope().node);
              }
            }
          }
        }
      },

      /* Function enter/exit */
      FunctionExpression: onFunctionEnter,
      ArrowFunctionExpression: onFunctionEnter,
      FunctionDeclaration: onFunctionEnter,
      Program: onFunctionEnter,
      "FunctionExpression:exit": onFunctionExit,
      "ArrowFunctionExpression:exit": onFunctionExit,
      "FunctionDeclaration:exit": onFunctionExit,
      "Program:exit": onFunctionExit,

      /* Detect JSX for adding props */
      JSXElement() {
        if (scopeStack.length) {
          currentScope().hasJSX = true;
        }
      },
      JSXFragment() {
        if (scopeStack.length) {
          currentScope().hasJSX = true;
        }
      },
    };
  },
});
