'use strict';

const path = require('path');

/**
 * This file contains any locally defined ESLint rules. They are picked up by
 * eslint-plugin-n8n-local-rules and exposed as 'n8n-local-rules/<rule-name>'.
 */
module.exports = {
	/**
	 * A rule to detect calls to JSON.parse() that are not wrapped inside try/catch blocks.
	 *
	 * Valid:
	 * ```js
	 * try { JSON.parse(foo) } catch(err) { baz() }
	 * ```
	 *
	 * Invalid:
	 * ```js
	 * JSON.parse(foo)
	 * ```
	 *
	 * The pattern where an object is cloned with JSON.parse(JSON.stringify()) is allowed
	 * (abundant in the n8n codebase):
	 *
	 * Valid:
	 * ```js
	 * JSON.parse(JSON.stringify(foo))
	 * ```
	 */
	'no-uncaught-json-parse': {
		meta: {
			type: 'problem',
			docs: {
				description:
					'Calls to `JSON.parse()` must be replaced with `jsonParse()` from `n8n-workflow` or surrounded with a try/catch block.',
				recommended: 'error',
			},
			schema: [],
			messages: {
				noUncaughtJsonParse:
					'Use `jsonParse()` from `n8n-workflow` or surround the `JSON.parse()` call with a try/catch block.',
			},
		},
		defaultOptions: [],
		create(context) {
			return {
				CallExpression(node) {
					if (!isJsonParseCall(node)) {
						return;
					}

					if (isJsonStringifyCall(node)) {
						return;
					}

					if (context.getAncestors().find((node) => node.type === 'TryStatement') !== undefined) {
						return;
					}

					// Found a JSON.parse() call not wrapped into a try/catch, so report it
					context.report({
						messageId: 'noUncaughtJsonParse',
						node,
					});
				},
			};
		},
	},

	'no-json-parse-json-stringify': {
		meta: {
			type: 'problem',
			docs: {
				description:
					'Calls to `JSON.parse(JSON.stringify(arg))` must be replaced with `deepCopy(arg)` from `n8n-workflow`.',
				recommended: 'error',
			},
			messages: {
				noJsonParseJsonStringify: 'Replace with `deepCopy({{ argText }})`',
			},
			fixable: 'code',
		},
		create(context) {
			return {
				CallExpression(node) {
					if (isJsonParseCall(node) && isJsonStringifyCall(node)) {
						const [callExpression] = node.arguments;

						const { arguments: args } = callExpression;

						if (!Array.isArray(args) || args.length !== 1) return;

						const [arg] = args;

						if (!arg) return;

						const argText = context.getSourceCode().getText(arg);

						context.report({
							messageId: 'noJsonParseJsonStringify',
							node,
							data: { argText },
							fix: (fixer) => fixer.replaceText(node, `deepCopy(${argText})`),
						});
					}
				},
			};
		},
	},

	'no-unneeded-backticks': {
		meta: {
			type: 'problem',
			docs: {
				description:
					'Template literal backticks may only be used for string interpolation or multiline strings.',
				recommended: 'error',
			},
			messages: {
				noUneededBackticks: 'Use single or double quotes, not backticks',
			},
			fixable: 'code',
		},
		create(context) {
			return {
				TemplateLiteral(node) {
					if (node.expressions.length > 0) return;
					if (node.quasis.every((q) => q.loc.start.line !== q.loc.end.line)) return;

					node.quasis.forEach((q) => {
						const escaped = q.value.raw.replace(/(?<!\\)'/g, "\\'");

						context.report({
							messageId: 'noUneededBackticks',
							node,
							fix: (fixer) => fixer.replaceText(q, `'${escaped}'`),
						});
					});
				},
			};
		},
	},

	'no-unused-param-in-catch-clause': {
		meta: {
			type: 'problem',
			docs: {
				description: 'Unused param in catch clause must be omitted.',
				recommended: 'error',
			},
			messages: {
				removeUnusedParam: 'Remove unused param in catch clause',
			},
			fixable: 'code',
		},
		create(context) {
			return {
				CatchClause(node) {
					if (node.param?.name?.startsWith('_')) {
						const start = node.range[0] + 'catch '.length;
						const end = node.param.range[1] + '()'.length;

						context.report({
							messageId: 'removeUnusedParam',
							node,
							fix: (fixer) => fixer.removeRange([start, end]),
						});
					}
				},
			};
		},
	},

	'no-skipped-tests': {
		meta: {
			type: 'problem',
			docs: {
				description: 'Tests must not be skipped.',
				recommended: 'error',
			},
			messages: {
				removeSkip: 'Remove `.skip()` call',
				removeOnly: 'Remove `.only()` call',
				removeXPrefix: 'Remove `x` prefix',
			},
			fixable: 'code',
		},
		create(context) {
			const TESTING_FUNCTIONS = new Set(['test', 'it', 'describe']);
			const SKIPPING_METHODS = new Set(['skip', 'only']);
			const PREFIXED_TESTING_FUNCTIONS = new Set(['xtest', 'xit', 'xdescribe']);
			const toMessageId = (s) => 'remove' + s.charAt(0).toUpperCase() + s.slice(1);

			return {
				MemberExpression(node) {
					if (
						node.object.type === 'Identifier' &&
						TESTING_FUNCTIONS.has(node.object.name) &&
						node.property.type === 'Identifier' &&
						SKIPPING_METHODS.has(node.property.name)
					) {
						context.report({
							messageId: toMessageId(node.property.name),
							node,
							fix: (fixer) => {
								const [start, end] = node.property.range;
								return fixer.removeRange([start - '.'.length, end]);
							},
						});
					}
				},
				CallExpression(node) {
					if (
						node.callee.type === 'Identifier' &&
						PREFIXED_TESTING_FUNCTIONS.has(node.callee.name)
					) {
						context.report({
							messageId: 'removeXPrefix',
							node,
							fix: (fixer) => fixer.replaceText(node.callee, 'test'),
						});
					}
				},
			};
		},
	},

	'no-interpolation-in-regular-string': {
		meta: {
			type: 'problem',
			docs: {
				description:
					'String interpolation `${...}` requires backticks, not single or double quotes.',
				recommended: 'error',
			},
			messages: {
				useBackticks: 'Use backticks to interpolate',
			},
			fixable: 'code',
		},
		create(context) {
			return {
				Literal(node) {
					if (typeof node.value !== 'string') return;

					if (/\$\{/.test(node.value)) {
						context.report({
							messageId: 'useBackticks',
							node,
							fix: (fixer) => fixer.replaceText(node, `\`${node.value}\``),
						});
					}
				},
			};
		},
	},

	'dangerously-use-html-string-missing': {
		meta: {
			type: 'error',
			docs: {
				description:
					'Calls to the `showToast` and `showMessage` methods must include `dangerouslyUseHTMLString: true` when at least one of the values in `title` or `message` contains HTML.',
				recommended: 'error',
				/**
				 * @note This rule does not yet cover cases where the result of calling
				 * `this.$locale.someMethod()` is assigned to a variable that is then
				 * assigned to `title or `message`, e.g. `message: errorMessage`.
				 */
			},
		},
		create(context) {
			const cwd = context.getCwd();
			const locale = 'src/plugins/i18n/locales/en.json';

			const LOCALE_NAMESPACE = '$locale';
			const LOCALE_FILEPATH = cwd.endsWith('editor-ui')
				? path.join(cwd, locale)
				: path.join(cwd, 'packages/editor-ui', locale);

			let LOCALE_MAP;

			try {
				LOCALE_MAP = JSON.parse(require('fs').readFileSync(LOCALE_FILEPATH));
			} catch {
				console.log(
					'[dangerously-use-html-string-missing] Failed to load locale map, skipping rule...',
				);
				return {};
			}

			const METHODS_POSSIBLY_REQUIRING_HTML = new Set(['showToast', 'showMessage']);
			const PROPERTIES_POSSIBLY_CONTAINING_HTML = new Set(['title', 'message']);
			const USE_HTML_PROPERTY = 'dangerouslyUseHTMLString';

			const isMethodPossiblyRequiringRawHtml = (node) =>
				node.callee.type === 'MemberExpression' &&
				node.callee.object.type === 'ThisExpression' &&
				node.callee.property.type === 'Identifier' &&
				METHODS_POSSIBLY_REQUIRING_HTML.has(node.callee.property.name) &&
				node.arguments.length === 1 &&
				node.arguments.at(0).type === 'ObjectExpression';

			const isPropertyWithLocaleStringAsValue = (property) =>
				property.key.type === 'Identifier' &&
				PROPERTIES_POSSIBLY_CONTAINING_HTML.has(property.key.name) &&
				property.value.type === 'CallExpression' &&
				property.value.callee.type === 'MemberExpression' &&
				property.value.callee.object.type === 'MemberExpression' &&
				property.value.callee.object.property.type === 'Identifier' &&
				property.value.callee.object.property.name === LOCALE_NAMESPACE &&
				property.value.arguments.length >= 1 &&
				property.value.arguments.at(0).type === 'Literal' &&
				typeof property.value.arguments.at(0).value === 'string';

			const containsHtml = (str) => {
				let insideTag = false;

				for (let char of str) {
					if (char === '<') {
						insideTag = true;
					} else if (char === '>') {
						if (insideTag) return true;
						insideTag = false;
					}
				}

				return false;
			};

			return {
				CallExpression(node) {
					if (!isMethodPossiblyRequiringRawHtml(node)) return;

					const arg = node.arguments.at(0);

					const hasArgWitHtml = arg.properties
						.reduce(
							(acc, p) =>
								isPropertyWithLocaleStringAsValue(p)
									? [...acc, p.value.arguments.at(0).value]
									: acc,
							[],
						)
						.some((i) => containsHtml(LOCALE_MAP[i]));

					if (!hasArgWitHtml) return;

					const hasRawHtmlPropertyAsTrue = arg.properties.some(
						(p) =>
							p.key.type === 'Identifier' &&
							p.key.name === USE_HTML_PROPERTY &&
							p.value.type === 'Literal' &&
							p.value.value === true,
					);

					if (hasRawHtmlPropertyAsTrue) return;

					const methodName = node.callee.property.name;

					context.report({
						node,
						message: `Set \`${USE_HTML_PROPERTY}: true\` in the argument to \`${methodName}\`. At least one of the values in \`title\` or \`message\` contains HTML.`,
					});
				},
			};
		},
	},

	'no-plain-errors': {
		meta: {
			type: 'problem',
			docs: {
				description:
					'Only `ApplicationError` (from the `workflow` package) or its child classes must be thrown. This ensures the error will be normalized when reported to Sentry, if applicable.',
				recommended: 'error',
			},
			messages: {
				useApplicationError:
					'Throw an `ApplicationError` (from the `workflow` package) or its child classes.',
			},
			fixable: 'code',
		},
		create(context) {
			return {
				ThrowStatement(node) {
					if (!node.argument) return;

					const isNewError =
						node.argument.type === 'NewExpression' && node.argument.callee.name === 'Error';

					const isNewlessError =
						node.argument.type === 'CallExpression' && node.argument.callee.name === 'Error';

					if (isNewError || isNewlessError) {
						return context.report({
							messageId: 'useApplicationError',
							node,
							fix: (fixer) =>
								fixer.replaceText(
									node,
									`throw new ApplicationError(${node.argument.arguments
										.map((arg) => arg.raw)
										.join(', ')})`,
								),
						});
					}
				},
			};
		},
	},

	'no-dynamic-import-template': {
		meta: {
			type: 'error',
			docs: {
				description:
					'Disallow non-relative imports in template string argument to `await import()`, because `tsc-alias` as of 1.8.7 is unable to resolve aliased paths in this scenario.',
				recommended: true,
			},
		},
		create: function (context) {
			return {
				'AwaitExpression > ImportExpression TemplateLiteral'(node) {
					const templateValue = node.quasis[0].value.cooked;

					if (!templateValue?.startsWith('@/')) return;

					context.report({
						node,
						message:
							'Use relative imports in template string argument to `await import()`, because `tsc-alias` as of 1.8.7 is unable to resolve aliased paths in this scenario.',
					});
				},
			};
		},
	},

	'misplaced-n8n-typeorm-import': {
		meta: {
			type: 'error',
			docs: {
				description:
					'Ensure `@n8n/typeorm` is imported only from within the `packages/cli/src/databases` directory.',
				recommended: 'error',
			},
			messages: {
				moveImport: 'Move this import to `packages/cli/src/databases/**/*.ts`.',
			},
		},
		create(context) {
			return {
				ImportDeclaration(node) {
					if (
						node.source.value === '@n8n/typeorm' &&
						!context.getFilename().includes('packages/cli/src/databases/')
					) {
						context.report({ node, messageId: 'moveImport' });
					}
				},
			};
		},
	},

	'no-type-unsafe-event-emitter': {
		meta: {
			type: 'problem',
			docs: {
				description: 'Disallow extending from `EventEmitter`, which is not type-safe.',
				recommended: 'error',
			},
			messages: {
				noExtendsEventEmitter: 'Extend from the type-safe `TypedEmitter` class instead.',
			},
		},
		create(context) {
			return {
				ClassDeclaration(node) {
					if (
						node.superClass &&
						node.superClass.type === 'Identifier' &&
						node.superClass.name === 'EventEmitter' &&
						node.id.name !== 'TypedEmitter'
					) {
						context.report({
							node: node.superClass,
							messageId: 'noExtendsEventEmitter',
						});
					}
				},
			};
		},
	},

	'no-untyped-config-class-field': {
		meta: {
			type: 'problem',
			docs: {
				description: 'Enforce explicit typing of config class fields',
				recommended: 'error',
			},
			messages: {
				noUntypedConfigClassField:
					'Class field must have an explicit type annotation, e.g. `field: type = value`. See: https://github.com/n8n-io/n8n/pull/10433',
			},
		},
		create(context) {
			return {
				PropertyDefinition(node) {
					if (!node.typeAnnotation) {
						context.report({ node: node.key, messageId: 'noUntypedConfigClassField' });
					}
				},
			};
		},
	},
};

const isJsonParseCall = (node) =>
	node.callee.type === 'MemberExpression' &&
	node.callee.object.type === 'Identifier' &&
	node.callee.object.name === 'JSON' &&
	node.callee.property.type === 'Identifier' &&
	node.callee.property.name === 'parse';

const isJsonStringifyCall = (node) => {
	const parseArg = node.arguments?.[0];
	return (
		parseArg !== undefined &&
		parseArg.type === 'CallExpression' &&
		parseArg.callee.type === 'MemberExpression' &&
		parseArg.callee.object.type === 'Identifier' &&
		parseArg.callee.object.name === 'JSON' &&
		parseArg.callee.property.type === 'Identifier' &&
		parseArg.callee.property.name === 'stringify'
	);
};
