import globals from 'globals';
import {functionTypes} from './ast/index.js';

const MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE = 'externally-scoped-variable';
const messages = {
	[MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE]: 'Variable {{name}} not defined in scope of isolated function. Function is isolated because: {{reason}}.',
};

/** @type {{functions: string[], selectors: string[], comments: string[], overrideGlobals?: import('eslint').Linter.Globals}} */
const defaultOptions = {
	functions: ['makeSynchronous'],
	selectors: [],
	comments: ['@isolated'],
	overrideGlobals: {},
};

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
	const {sourceCode} = context;
	/** @type {typeof defaultOptions} */
	const options = {...context.options[0]};

	options.comments = options.comments.map(comment => comment.toLowerCase());

	const allowedGlobals = {
		...(globals[`es${context.languageOptions.ecmaVersion}`] ?? globals.builtins),
		...context.languageOptions.globals,
		...options.overrideGlobals,
	};
	const checked = new WeakSet();

	/** @param {import('estree').Node} node */
	const checkForExternallyScopedVariables = (node, reason) => {
		if (checked.has(node) || !functionTypes.includes(node.type)) {
			return;
		}

		checked.add(node);

		const nodeScope = sourceCode.getScope(node);

		// `through`: "The array of references which could not be resolved in this scope" https://eslint.org/docs/latest/extend/scope-manager-interface#scope-interface
		for (const reference of nodeScope.through) {
			const {identifier} = reference;

			if (identifier.parent.type === 'TSTypeReference' || identifier.parent.type === 'TSTypeQuery') {
				continue;
			}

			if (identifier.name in allowedGlobals && allowedGlobals[identifier.name] !== 'off') {
				if (reference.isReadOnly()) {
					continue;
				}

				const globalsValue = allowedGlobals[identifier.name];
				const isGlobalWritable = globalsValue === true || globalsValue === 'writable' || globalsValue === 'writeable';
				if (isGlobalWritable) {
					continue;
				}

				reason += ' (global variable is not writable)';
			}

			// Could consider checking for typeof operator here, like in no-undef?

			context.report({
				node: identifier,
				messageId: MESSAGE_ID_EXTERNALLY_SCOPED_VARIABLE,
				data: {name: identifier.name, reason},
			});
		}
	};

	const isComment = token => token?.type === 'Block' || token?.type === 'Line';

	/**
	 Find a comment on this node or its parent, in cases where the node passed is part of a variable or export declaration.
	 @param {import('estree').Node} node
	 */
	const findComment = node => {
		let previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
		let commentableNode = node;
		while (
			!isComment(previousToken)
			&& (commentableNode.parent.type === 'VariableDeclarator'
				|| commentableNode.parent.type === 'VariableDeclaration'
				|| commentableNode.parent.type === 'ExportNamedDeclaration'
				|| commentableNode.parent.type === 'ExportDefaultDeclaration')
		) {
			commentableNode = commentableNode.parent;
			previousToken = sourceCode.getTokenBefore(commentableNode, {includeComments: true});
		}

		if (isComment(previousToken)) {
			return previousToken.value;
		}
	};

	/**
	 Find the string "reason" that a function (node) should be considered isolated. For passing in to `context.report(...)` when out-of-scope variables are found. Returns undefined if the function should not be considered isolated.
	 @param {import('estree').Node & {parent?: import('estree').Node}} node
	 */
	const reasonForBeingIsolatedFunction = node => {
		if (options.comments.length > 0) {
			let previousComment = findComment(node);

			if (previousComment) {
				previousComment = previousComment
					.replace(/(?:\*\s*)*/, '') // JSDoc comments like `/** @isolated */` are parsed into `* @isolated`. And `/**\n * @isolated */` is parsed into `*\n * @isolated`
					.trim()
					.toLowerCase();
				const match = options.comments.find(comment => previousComment === comment || previousComment.startsWith(`${comment} - `) || previousComment.startsWith(`${comment} -- `));
				if (match) {
					return `follows comment ${JSON.stringify(match)}`;
				}
			}
		}

		if (
			options.functions.length > 0
			&& node.parent.type === 'CallExpression'
			&& node.parent.arguments.includes(node)
			&& node.parent.callee.type === 'Identifier'
			&& options.functions.includes(node.parent.callee.name)
		) {
			return `callee of function named ${JSON.stringify(node.parent.callee.name)}`;
		}
	};

	context.onExit(
		functionTypes,
		node => {
			const reason = reasonForBeingIsolatedFunction(node);
			if (!reason) {
				return;
			}

			return checkForExternallyScopedVariables(node, reason);
		},
	);

	for (const selector of options.selectors) {
		context.onExit(
			selector,
			node => {
				const reason = `matches selector ${JSON.stringify(selector)}`;
				return checkForExternallyScopedVariables(node, reason);
			},
		);
	}
};

/** @type {import('json-schema').JSONSchema7[]} */
const schema = [
	{
		type: 'object',
		additionalProperties: false,
		properties: {
			overrideGlobals: {
				additionalProperties: {
					anyOf: [{type: 'boolean'}, {type: 'string', enum: ['readonly', 'writable', 'writeable', 'off']}],
				},
				description: 'Override which global variables are allowed inside isolated scopes.',
			},
			functions: {
				type: 'array',
				uniqueItems: true,
				items: {
					type: 'string',
				},
				description: 'Function names that mark a scope as isolated.',
			},
			selectors: {
				type: 'array',
				uniqueItems: true,
				items: {
					type: 'string',
				},
				description: 'AST selectors that mark a scope as isolated.',
			},
			comments: {
				type: 'array',
				uniqueItems: true,
				items: {
					type: 'string',
				},
				description: 'Comment patterns that mark a scope as isolated.',
			},
		},
	},
];

/** @type {import('eslint').Rule.RuleModule} */
export default {
	create,
	meta: {
		type: 'problem',
		docs: {
			description: 'Prevent usage of variables from outside the scope of isolated functions.',
			recommended: true,
		},
		schema,
		defaultOptions: [defaultOptions],
		messages,
	},
};
