/**
 * FIXME: remove this comments and import when below issue is fixed.
 * This import is necessary for type generation due to a bug in the TypeScript compiler.
 * See: https://github.com/microsoft/TypeScript/issues/42873
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { TSESLint } from "@typescript-eslint/utils";

import { TSESTree as T, ESLintUtils } from "@typescript-eslint/utils";
import { isDOMElementName, formatList, appendImports, insertImports } from "../utils";
import { getScope, getSourceCode } from "../compat";

const createRule = ESLintUtils.RuleCreator.withoutDocs;

// Currently all of the control flow components are from 'solid-js'.
const AUTO_COMPONENTS = ["Show", "For", "Index", "Switch", "Match"];
const SOURCE_MODULE = "solid-js";

/*
 * This rule is adapted from eslint-plugin-react's jsx-no-undef rule under
 * the MIT license. Thank you for your work!
 */
type MessageIds = "undefined" | "customDirectiveUndefined" | "autoImport";
type Options = [
  {
    allowGlobals?: boolean;
    autoImport?: boolean;
    typescriptEnabled?: boolean;
  }?
];
export default createRule<Options, MessageIds>({
  meta: {
    type: "problem",
    docs: {
      description: "Disallow references to undefined variables in JSX. Handles custom directives.",
      url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/jsx-no-undef.md",
    },
    fixable: "code",
    schema: [
      {
        type: "object",
        properties: {
          allowGlobals: {
            type: "boolean",
            description:
              "When true, the rule will consider the global scope when checking for defined components.",
            default: false,
          },
          autoImport: {
            type: "boolean",
            description:
              'Automatically import certain components from `"solid-js"` if they are undefined.',
            default: true,
          },
          typescriptEnabled: {
            type: "boolean",
            description: "Adjusts behavior not to conflict with TypeScript's type checking.",
            default: false,
          },
        },
        additionalProperties: false,
      },
    ],
    messages: {
      undefined: "'{{identifier}}' is not defined.",
      customDirectiveUndefined: "Custom directive '{{identifier}}' is not defined.",
      autoImport: "{{imports}} should be imported from '{{source}}'.",
    },
  },
  defaultOptions: [],
  create(context) {
    const allowGlobals = context.options[0]?.allowGlobals ?? false;
    const autoImport = context.options[0]?.autoImport !== false;
    const isTypeScriptEnabled = context.options[0]?.typescriptEnabled ?? false;

    const missingComponentsSet = new Set<string>();

    /**
     * Compare an identifier with the variables declared in the scope
     * @param {ASTNode} node - Identifier or JSXIdentifier node
     * @returns {void}
     */
    function checkIdentifierInJSX(
      node: T.Identifier | T.JSXIdentifier,
      {
        isComponent,
        isCustomDirective,
      }: { isComponent?: boolean; isCustomDirective?: boolean } = {}
    ) {
      let scope = getScope(context, node);
      const sourceCode = getSourceCode(context);
      const sourceType = sourceCode.ast.sourceType;
      const scopeUpperBound = !allowGlobals && sourceType === "module" ? "module" : "global";
      const variables = [...scope.variables];

      // Ignore 'this' keyword (also maked as JSXIdentifier when used in JSX)
      if (node.name === "this") {
        return;
      }

      while (scope.type !== scopeUpperBound && scope.type !== "global" && scope.upper) {
        scope = scope.upper;
        variables.push(...scope.variables);
      }
      if (scope.childScopes.length) {
        variables.push(...scope.childScopes[0].variables);
        // Temporary fix for babel-eslint
        if (scope.childScopes[0].childScopes.length) {
          variables.push(...scope.childScopes[0].childScopes[0].variables);
        }
      }

      if (variables.find((variable) => variable.name === node.name)) {
        return;
      }

      if (
        isComponent &&
        autoImport &&
        AUTO_COMPONENTS.includes(node.name) &&
        !missingComponentsSet.has(node.name)
      ) {
        // track which names are undefined
        missingComponentsSet.add(node.name);
      } else if (isCustomDirective) {
        context.report({
          node,
          messageId: "customDirectiveUndefined",
          data: {
            identifier: node.name,
          },
        });
      } else if (!isTypeScriptEnabled) {
        context.report({
          node,
          messageId: "undefined",
          data: {
            identifier: node.name,
          },
        });
      }
    }

    return {
      JSXOpeningElement(node) {
        let n: T.Node | undefined;
        switch (node.name.type) {
          case "JSXIdentifier":
            if (!isDOMElementName(node.name.name)) {
              checkIdentifierInJSX(node.name, { isComponent: true });
            }
            break;
          case "JSXMemberExpression":
            n = node.name;
            do {
              n = (n as any).object;
            } while (n && n.type !== "JSXIdentifier");
            if (n) {
              checkIdentifierInJSX(n);
            }
            break;
          default:
            break;
        }
      },
      "JSXAttribute > JSXNamespacedName": (node: T.JSXNamespacedName) => {
        // <Element use:X /> applies the `X` custom directive to the element, where `X` must be an identifier in scope.
        if (
          node.namespace?.type === "JSXIdentifier" &&
          node.namespace.name === "use" &&
          node.name?.type === "JSXIdentifier"
        ) {
          checkIdentifierInJSX(node.name, { isCustomDirective: true });
        }
      },
      "Program:exit": (programNode: T.Program) => {
        // add in any auto import components used in the program
        const missingComponents = Array.from(missingComponentsSet.values());
        if (autoImport && missingComponents.length) {
          const importNode = programNode.body.find(
            (n) =>
              n.type === "ImportDeclaration" &&
              n.importKind !== "type" &&
              n.source.type === "Literal" &&
              n.source.value === SOURCE_MODULE
          ) as T.ImportDeclaration | undefined;
          if (importNode) {
            context.report({
              node: importNode,
              messageId: "autoImport",
              data: {
                imports: formatList(missingComponents), // "Show, For, and Switch"
                source: SOURCE_MODULE,
              },
              fix: (fixer) => {
                return appendImports(fixer, getSourceCode(context), importNode, missingComponents);
              },
            });
          } else {
            context.report({
              node: programNode,
              messageId: "autoImport",
              data: {
                imports: formatList(missingComponents),
                source: SOURCE_MODULE,
              },
              fix: (fixer) => {
                // insert `import { missing, identifiers } from "solid-js"` at top of module
                return insertImports(fixer, getSourceCode(context), "solid-js", missingComponents);
              },
            });
          }
        }
      },
    };
  },
});
