import {getPropertyName, ReferenceTracker} from '@eslint-community/eslint-utils';
import {fixSpaceAroundKeyword} from './fix/index.js';
import {
	isEmptyArrayExpression,
	isEmptyObjectExpression,
	isMemberExpression,
	isMethodCall,
} from './ast/index.js';

const messages = {
	'known-method': 'Prefer using `{{constructorName}}.prototype.{{methodName}}`.',
	'unknown-method': 'Prefer using method from `{{constructorName}}.prototype`.',
};

const OBJECT_PROTOTYPE_METHODS = [
	'hasOwnProperty',
	'isPrototypeOf',
	'propertyIsEnumerable',
	'toLocaleString',
	'toString',
	'valueOf',
];

function getConstructorAndMethodName(methodNode, {context, globalReferences}) {
	if (!methodNode) {
		return;
	}

	const isGlobalReference = globalReferences.has(methodNode);
	if (isGlobalReference) {
		const path = globalReferences.get(methodNode);
		return {
			isGlobalReference: true,
			constructorName: 'Object',
			methodName: path.at(-1),
		};
	}

	if (!isMemberExpression(methodNode, {optional: false})) {
		return;
	}

	const objectNode = methodNode.object;

	if (!(isEmptyArrayExpression(objectNode) || isEmptyObjectExpression(objectNode))) {
		return;
	}

	const constructorName = objectNode.type === 'ArrayExpression' ? 'Array' : 'Object';
	const methodName = getPropertyName(methodNode, context.sourceCode.getScope(methodNode));

	return {
		constructorName,
		methodName,
	};
}

function getProblem(callExpression, {context, globalReferences}) {
	let methodNode;

	if (
		// `Reflect.apply([].foo, …)`
		// `Reflect.apply({}.foo, …)`
		isMethodCall(callExpression, {
			object: 'Reflect',
			method: 'apply',
			minimumArguments: 1,
			optionalCall: false,
			optionalMember: false,
		})
	) {
		methodNode = callExpression.arguments[0];
	} else if (
		// `[].foo.{apply,bind,call}(…)`
		// `({}).foo.{apply,bind,call}(…)`
		isMethodCall(callExpression, {
			methods: ['apply', 'bind', 'call'],
			optionalCall: false,
			optionalMember: false,
		})
	) {
		methodNode = callExpression.callee.object;
	}

	const {
		isGlobalReference,
		constructorName,
		methodName,
	} = getConstructorAndMethodName(methodNode, {context, globalReferences}) ?? {};

	if (!constructorName) {
		return;
	}

	return {
		node: methodNode,
		messageId: methodName ? 'known-method' : 'unknown-method',
		data: {constructorName, methodName},
		* fix(fixer) {
			if (isGlobalReference) {
				yield fixer.replaceText(methodNode, `${constructorName}.prototype.${methodName}`);
				return;
			}

			if (isMemberExpression(methodNode)) {
				const objectNode = methodNode.object;

				yield fixer.replaceText(objectNode, `${constructorName}.prototype`);

				if (
					objectNode.type === 'ArrayExpression'
					|| objectNode.type === 'ObjectExpression'
				) {
					yield fixSpaceAroundKeyword(fixer, callExpression, context);
				}
			}
		},
	};
}

/** @param {import('eslint').Rule.RuleContext} context */
function create(context) {
	const {sourceCode} = context;
	const callExpressions = [];

	context.on('CallExpression', callExpression => {
		callExpressions.push(callExpression);
	});

	context.onExit('Program', function * (program) {
		const globalReferences = new WeakMap();

		const tracker = new ReferenceTracker(sourceCode.getScope(program));

		for (const {node, path} of tracker.iterateGlobalReferences(
			Object.fromEntries(OBJECT_PROTOTYPE_METHODS.map(method => [method, {[ReferenceTracker.READ]: true}])),
		)) {
			globalReferences.set(node, path);
		}

		for (const callExpression of callExpressions) {
			yield getProblem(callExpression, {
				context,
				globalReferences,
			});
		}
	});
}

/** @type {import('eslint').Rule.RuleModule} */
const config = {
	create,
	meta: {
		type: 'suggestion',
		docs: {
			description: 'Prefer borrowing methods from the prototype instead of the instance.',
			recommended: 'unopinionated',
		},
		fixable: 'code',
		messages,
	},
};

export default config;
