/**
 * 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, ASTUtils } from "@typescript-eslint/utils";
import { isDOMElementName } from "../utils";
import { getScope, getSourceCode } from "../compat";

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

const COMMON_EVENTS = [
  "onAnimationEnd",
  "onAnimationIteration",
  "onAnimationStart",
  "onBeforeInput",
  "onBlur",
  "onChange",
  "onClick",
  "onContextMenu",
  "onCopy",
  "onCut",
  "onDblClick",
  "onDrag",
  "onDragEnd",
  "onDragEnter",
  "onDragExit",
  "onDragLeave",
  "onDragOver",
  "onDragStart",
  "onDrop",
  "onError",
  "onFocus",
  "onFocusIn",
  "onFocusOut",
  "onGotPointerCapture",
  "onInput",
  "onInvalid",
  "onKeyDown",
  "onKeyPress",
  "onKeyUp",
  "onLoad",
  "onLostPointerCapture",
  "onMouseDown",
  "onMouseEnter",
  "onMouseLeave",
  "onMouseMove",
  "onMouseOut",
  "onMouseOver",
  "onMouseUp",
  "onPaste",
  "onPointerCancel",
  "onPointerDown",
  "onPointerEnter",
  "onPointerLeave",
  "onPointerMove",
  "onPointerOut",
  "onPointerOver",
  "onPointerUp",
  "onReset",
  "onScroll",
  "onSelect",
  "onSubmit",
  "onToggle",
  "onTouchCancel",
  "onTouchEnd",
  "onTouchMove",
  "onTouchStart",
  "onTransitionEnd",
  "onWheel",
] as const;
type CommonEvent = (typeof COMMON_EVENTS)[number];

const COMMON_EVENTS_MAP = new Map<string, CommonEvent>(
  (function* () {
    for (const event of COMMON_EVENTS) {
      yield [event.toLowerCase(), event] as const;
    }
  })()
);

const NONSTANDARD_EVENTS_MAP = {
  ondoubleclick: "onDblClick",
};

const isCommonHandlerName = (
  lowercaseHandlerName: string
): lowercaseHandlerName is Lowercase<CommonEvent> => COMMON_EVENTS_MAP.has(lowercaseHandlerName);
const getCommonEventHandlerName = (lowercaseHandlerName: Lowercase<CommonEvent>): CommonEvent =>
  COMMON_EVENTS_MAP.get(lowercaseHandlerName)!;

const isNonstandardEventName = (
  lowercaseEventName: string
): lowercaseEventName is keyof typeof NONSTANDARD_EVENTS_MAP =>
  Boolean((NONSTANDARD_EVENTS_MAP as Record<string, string>)[lowercaseEventName]);
const getStandardEventHandlerName = (lowercaseEventName: keyof typeof NONSTANDARD_EVENTS_MAP) =>
  NONSTANDARD_EVENTS_MAP[lowercaseEventName];

type MessageIds =
  | "naming"
  | "capitalization"
  | "nonstandard"
  | "make-handler"
  | "make-attr"
  | "detected-attr"
  | "spread-handler";
type Options = [{ ignoreCase?: boolean; warnOnSpread?: boolean }?];

export default createRule<Options, MessageIds>({
  meta: {
    type: "problem",
    docs: {
      description:
        "Enforce naming DOM element event handlers consistently and prevent Solid's analysis from misunderstanding whether a prop should be an event handler.",
      url: "https://github.com/solidjs-community/eslint-plugin-solid/blob/main/packages/eslint-plugin-solid/docs/event-handlers.md",
    },
    fixable: "code",
    hasSuggestions: true,
    schema: [
      {
        type: "object",
        properties: {
          ignoreCase: {
            type: "boolean",
            description:
              "if true, don't warn on ambiguously named event handlers like `onclick` or `onchange`",
            default: false,
          },
          warnOnSpread: {
            type: "boolean",
            description:
              "if true, warn when spreading event handlers onto JSX. Enable for Solid < v1.6.",
            default: false,
          },
        },
        additionalProperties: false,
      },
    ],
    messages: {
      "detected-attr":
        'The {{name}} prop is named as an event handler (starts with "on"), but Solid knows its value ({{staticValue}}) is a string or number, so it will be treated as an attribute. If this is intentional, name this prop attr:{{name}}.',
      naming:
        "The {{name}} prop is ambiguous. If it is an event handler, change it to {{handlerName}}. If it is an attribute, change it to {{attrName}}.",
      capitalization: "The {{name}} prop should be renamed to {{fixedName}} for readability.",
      nonstandard:
        "The {{name}} prop should be renamed to {{fixedName}}, because it's not a standard event handler.",
      "make-handler": "Change the {{name}} prop to {{handlerName}}.",
      "make-attr": "Change the {{name}} prop to {{attrName}}.",
      "spread-handler":
        "The {{name}} prop should be added as a JSX attribute, not spread in. Solid doesn't add listeners when spreading into JSX.",
    },
  },
  defaultOptions: [],
  create(context) {
    const sourceCode = getSourceCode(context);

    return {
      JSXAttribute(node) {
        const openingElement = node.parent as T.JSXOpeningElement;
        if (
          openingElement.name.type !== "JSXIdentifier" ||
          !isDOMElementName(openingElement.name.name)
        ) {
          return; // bail if this is not a DOM/SVG element or web component
        }

        if (node.name.type === "JSXNamespacedName") {
          return; // bail early on attr:, on:, oncapture:, etc. props
        }

        // string name of the name node
        const { name } = node.name;

        if (!/^on[a-zA-Z]/.test(name)) {
          return; // bail if Solid doesn't consider the prop name an event handler
        }

        let staticValue: ReturnType<typeof getStaticValue> = null;
        if (
          node.value?.type === "JSXExpressionContainer" &&
          node.value.expression.type !== "JSXEmptyExpression" &&
          node.value.expression.type !== "ArrayExpression" && // array syntax prevents inlining
          (staticValue = getStaticValue(node.value.expression, getScope(context, node))) !== null &&
          (typeof staticValue.value === "string" || typeof staticValue.value === "number")
        ) {
          // One of the first things Solid (actually babel-plugin-dom-expressions) does with an
          // attribute is determine if it can be inlined into a template string instead of
          // injected programmatically. It runs
          // `attribute.get("value").get("expression").evaluate().value` on attributes with
          // JSXExpressionContainers, and if the statically evaluated value is a string or number,
          // it inlines it. This runs even for attributes that follow the naming convention for
          // event handlers. By starting an attribute name with "on", the user has signalled that
          // they intend the attribute to be an event handler. If the attribute value would be
          // inlined, report that.
          // https://github.com/ryansolid/dom-expressions/blob/cb3be7558c731e2a442e9c7e07d25373c40cf2be/packages/babel-plugin-jsx-dom-expressions/src/dom/element.js#L347
          context.report({
            node,
            messageId: "detected-attr",
            data: {
              name,
              staticValue: staticValue.value,
            },
          });
        } else if (node.value === null || node.value?.type === "Literal") {
          // Check for same as above for literal values
          context.report({
            node,
            messageId: "detected-attr",
            data: {
              name,
              staticValue: node.value !== null ? node.value.value : true,
            },
          });
        } else if (!context.options[0]?.ignoreCase) {
          const lowercaseHandlerName = name.toLowerCase();
          if (isNonstandardEventName(lowercaseHandlerName)) {
            const fixedName = getStandardEventHandlerName(lowercaseHandlerName);
            context.report({
              node: node.name,
              messageId: "nonstandard",
              data: { name, fixedName },
              fix: (fixer) => fixer.replaceText(node.name, fixedName),
            });
          } else if (isCommonHandlerName(lowercaseHandlerName)) {
            const fixedName = getCommonEventHandlerName(lowercaseHandlerName);
            if (fixedName !== name) {
              // For common DOM event names, we know the user intended the prop to be an event handler.
              // Fix it to have an uppercase third letter and be properly camel-cased.
              context.report({
                node: node.name,
                messageId: "capitalization",
                data: { name, fixedName },
                fix: (fixer) => fixer.replaceText(node.name, fixedName),
              });
            }
          } else if (name[2] === name[2].toLowerCase()) {
            // this includes words like `only` and `ongoing` as well as unknown handlers like `onfoobar`.
            // Enforce using either /^on[A-Z]/ (event handler) or /^attr:on[a-z]/ (forced regular attribute)
            // to make user intent clear and code maximally readable
            const handlerName = `on${name[2].toUpperCase()}${name.slice(3)}`;
            const attrName = `attr:${name}`;
            context.report({
              node: node.name,
              messageId: "naming",
              data: { name, attrName, handlerName },
              suggest: [
                {
                  messageId: "make-handler",
                  data: { name, handlerName },
                  fix: (fixer) => fixer.replaceText(node.name, handlerName),
                },
                {
                  messageId: "make-attr",
                  data: { name, attrName },
                  fix: (fixer) => fixer.replaceText(node.name, attrName),
                },
              ],
            });
          }
        }
      },
      Property(node: T.Property) {
        if (
          context.options[0]?.warnOnSpread &&
          node.parent?.type === "ObjectExpression" &&
          node.parent.parent?.type === "JSXSpreadAttribute" &&
          node.parent.parent.parent?.type === "JSXOpeningElement"
        ) {
          const openingElement = node.parent.parent.parent;
          if (
            openingElement.name.type === "JSXIdentifier" &&
            isDOMElementName(openingElement.name.name)
          ) {
            if (node.key.type === "Identifier" && /^on/.test(node.key.name)) {
              const handlerName = node.key.name;
              // An event handler is being spread in (ex. <button {...{ onClick }} />), which doesn't
              // actually add an event listener, just a plain attribute.
              context.report({
                node,
                messageId: "spread-handler",
                data: {
                  name: node.key.name,
                },
                *fix(fixer) {
                  const commaAfter = sourceCode.getTokenAfter(node);
                  yield fixer.remove(
                    (node.parent as T.ObjectExpression).properties.length === 1
                      ? node.parent!.parent!
                      : node
                  );
                  if (commaAfter?.value === ",") {
                    yield fixer.remove(commaAfter);
                  }
                  yield fixer.insertTextAfter(
                    node.parent!.parent!,
                    ` ${handlerName}={${sourceCode.getText(node.value)}}`
                  );
                },
              });
            }
          }
        }
      },
    };
  },
});
