import {getParenthesizedText, getParenthesizedRange, isSameReference} from './utils/index.js';
import {isLiteral, isMethodCall} from './ast/index.js';
import {replaceNodeOrTokenAndSpacesBefore, removeParentheses} from './fix/index.js';

const MESSAGE_ID = 'prefer-modern-math-apis';
const messages = {
	[MESSAGE_ID]: 'Prefer `{{replacement}}` over `{{description}}`.',
};

const isMathProperty = (node, property) =>
	node.type === 'MemberExpression'
	&& !node.optional
	&& !node.computed
	&& node.object.type === 'Identifier'
	&& node.object.name === 'Math'
	&& node.property.type === 'Identifier'
	&& node.property.name === property;

const isMathMethodCall = (node, method) =>
	node.type === 'CallExpression'
	&& !node.optional
	&& isMathProperty(node.callee, method)
	&& node.arguments.length === 1
	&& node.arguments[0].type !== 'SpreadElement';

// `Math.log(x) * Math.LOG10E` -> `Math.log10(x)`
// `Math.LOG10E * Math.log(x)` -> `Math.log10(x)`
// `Math.log(x) * Math.LOG2E` -> `Math.log2(x)`
// `Math.LOG2E * Math.log(x)` -> `Math.log2(x)`
function createLogCallTimesConstantCheck({constantName, replacementMethod}) {
	const replacement = `Math.${replacementMethod}(…)`;

	return function (node, context) {
		if (!(node.type === 'BinaryExpression' && node.operator === '*')) {
			return;
		}

		let mathLogCall;
		let description;
		if (isMathMethodCall(node.left, 'log') && isMathProperty(node.right, constantName)) {
			mathLogCall = node.left;
			description = `Math.log(…) * Math.${constantName}`;
		} else if (isMathMethodCall(node.right, 'log') && isMathProperty(node.left, constantName)) {
			mathLogCall = node.right;
			description = `Math.${constantName} * Math.log(…)`;
		}

		if (!mathLogCall) {
			return;
		}

		const [valueNode] = mathLogCall.arguments;

		return {
			node,
			messageId: MESSAGE_ID,
			data: {
				replacement,
				description,
			},
			fix: fixer => fixer.replaceText(node, `Math.${replacementMethod}(${getParenthesizedText(valueNode, context)})`),
		};
	};
}

// `Math.log(x) / Math.LN10` -> `Math.log10(x)`
// `Math.log(x) / Math.LN2` -> `Math.log2(x)`
function createLogCallDivideConstantCheck({constantName, replacementMethod}) {
	const message = {
		messageId: MESSAGE_ID,
		data: {
			replacement: `Math.${replacementMethod}(…)`,
			description: `Math.log(…) / Math.${constantName}`,
		},
	};

	return function (node, context) {
		if (
			!(
				node.type === 'BinaryExpression'
				&& node.operator === '/'
				&& isMathMethodCall(node.left, 'log')
				&& isMathProperty(node.right, constantName)
			)
		) {
			return;
		}

		const [valueNode] = node.left.arguments;

		return {
			...message,
			node,
			fix: fixer => fixer.replaceText(node, `Math.${replacementMethod}(${getParenthesizedText(valueNode, context)})`),
		};
	};
}

const checkFunctions = [
	createLogCallTimesConstantCheck({constantName: 'LOG10E', replacementMethod: 'log10'}),
	createLogCallTimesConstantCheck({constantName: 'LOG2E', replacementMethod: 'log2'}),
	createLogCallDivideConstantCheck({constantName: 'LN10', replacementMethod: 'log10'}),
	createLogCallDivideConstantCheck({constantName: 'LN2', replacementMethod: 'log2'}),
];

const isPlusExpression = node => node.type === 'BinaryExpression' && node.operator === '+';

const isPow2Expression = node =>
	node.type === 'BinaryExpression'
	&& (
		// `x * x`
		(node.operator === '*' && isSameReference(node.left, node.right))
		// `x ** 2`
		|| (node.operator === '**' && isLiteral(node.right, 2))
	);

const flatPlusExpression = node =>
	isPlusExpression(node)
		? [node.left, node.right].flatMap(child => flatPlusExpression(child))
		: [node];

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

	context.on('CallExpression', callExpression => {
		if (!isMethodCall(callExpression, {
			object: 'Math',
			method: 'sqrt',
			argumentsLength: 1,
			optionalCall: false,
			optionalMember: false,
		})) {
			return;
		}

		const expressions = flatPlusExpression(callExpression.arguments[0]);
		if (expressions.some(expression => !isPow2Expression(expression))) {
			return;
		}

		const replacementMethod = expressions.length === 1 ? 'abs' : 'hypot';
		const plusExpressions = new Set(expressions.length === 1 ? [] : expressions.map(expression => expression.parent));

		return {
			node: callExpression.callee.property,
			messageId: MESSAGE_ID,
			data: {
				replacement: `Math.${replacementMethod}(…)`,
				description: 'Math.sqrt(…)',
			},
			* fix(fixer) {
				const {sourceCode} = context;

				// `Math.sqrt` -> `Math.{hypot,abs}`
				yield fixer.replaceText(callExpression.callee.property, replacementMethod);

				// `a ** 2 + b ** 2` -> `a, b`
				for (const expression of plusExpressions) {
					const plusToken = sourceCode.getTokenAfter(expression.left, token => token.type === 'Punctuator' && token.value === '+');

					yield replaceNodeOrTokenAndSpacesBefore(plusToken, ',', fixer, context);
					yield removeParentheses(expression, fixer, context);
				}

				// `x ** 2` => `x`
				// `x * a` => `x`
				for (const expression of expressions) {
					yield fixer.removeRange([
						getParenthesizedRange(expression.left, context)[1],
						sourceCode.getRange(expression)[1],
					]);
				}
			},
		};
	});

	context.on('BinaryExpression', node => {
		nodes.push(node);
	});

	context.on('Program:exit', function * () {
		for (const node of nodes) {
			for (const getProblem of checkFunctions) {
				const problem = getProblem(node, context);

				if (problem) {
					yield problem;
				}
			}
		}
	});
};

/** @type {import('eslint').Rule.RuleModule} */
const config = {
	create,
	meta: {
		type: 'suggestion',
		docs: {
			description: 'Prefer modern `Math` APIs over legacy patterns.',
			recommended: 'unopinionated',
		},
		fixable: 'code',
		messages,
	},
};

export default config;
