import {
	getParenthesizedText,
	isArrayPrototypeProperty,
	isNodeMatches,
	isNodeMatchesNameOrPath,
	isParenthesized,
	isSameIdentifier,
	needsSemicolon,
	shouldAddParenthesesToMemberExpressionObject,
} from './utils/index.js';
import {fixSpaceAroundKeyword} from './fix/index.js';
import {isMethodCall, isCallExpression, isEmptyArrayExpression} from './ast/index.js';

const MESSAGE_ID = 'prefer-array-flat';
const messages = {
	[MESSAGE_ID]: 'Prefer `Array#flat()` over `{{description}}` to flatten an array.',
};

// `array.flatMap(x => x)`
const arrayFlatMap = {
	testFunction(node) {
		if (!isMethodCall(node, {
			method: 'flatMap',
			argumentsLength: 1,
			optionalCall: false,
		})) {
			return false;
		}

		const [firstArgument] = node.arguments;
		return (
			firstArgument.type === 'ArrowFunctionExpression'
			&& !firstArgument.async
			&& firstArgument.params.length === 1
			&& isSameIdentifier(firstArgument.params[0], firstArgument.body)
		);
	},
	getArrayNode: node => node.callee.object,
	isOptionalArray: node => node.callee.optional,
	description: 'Array#flatMap()',
};

// `array.reduce((a, b) => a.concat(b), [])`
// `array?.reduce((a, b) => a.concat(b), [])`
// `array.reduce((a, b) => [...a, ...b], [])`
// `array?.reduce((a, b) => [...a, ...b], [])`
const arrayReduce = {
	testFunction(node) {
		if (!isMethodCall(node, {
			method: 'reduce',
			argumentsLength: 2,
			optionalCall: false,
		})) {
			return false;
		}

		const [firstArgument, secondArgument] = node.arguments;
		if (!(
			firstArgument.type === 'ArrowFunctionExpression'
			&& !firstArgument.async
			&& firstArgument.params.length === 2
			&& isEmptyArrayExpression(secondArgument)
		)) {
			return false;
		}

		const firstArgumentBody = firstArgument.body;
		const [firstParameter, secondParameter] = firstArgument.params;
		return (
			// `(a, b) => a.concat(b)`
			(
				isMethodCall(firstArgumentBody, {
					method: 'concat',
					argumentsLength: 1,
					optionalCall: false,
					optionalMember: false,
				})
				&& isSameIdentifier(firstParameter, firstArgumentBody.callee.object)
				&& isSameIdentifier(secondParameter, firstArgumentBody.arguments[0])
			)
			// `(a, b) => [...a, ...b]`
			|| (
				firstArgumentBody.type === 'ArrayExpression'
				&& firstArgumentBody.elements.length === 2
				&& firstArgumentBody.elements.every((node, index) =>
					node?.type === 'SpreadElement'
					&& node.argument.type === 'Identifier'
					&& isSameIdentifier(firstArgument.params[index], node.argument),
				)
			)
		);
	},
	getArrayNode: node => node.callee.object,
	isOptionalArray: node => node.callee.optional,
	description: 'Array#reduce()',
};

// `[].concat(maybeArray)`
// `[].concat(...array)`
const emptyArrayConcat = {
	testFunction(node) {
		return isMethodCall(node, {
			method: 'concat',
			argumentsLength: 1,
			allowSpreadElement: true,
			optionalCall: false,
			optionalMember: false,
		})
		&& isEmptyArrayExpression(node.callee.object);
	},
	getArrayNode(node) {
		const argumentNode = node.arguments[0];
		return argumentNode.type === 'SpreadElement' ? argumentNode.argument : argumentNode;
	},
	description: '[].concat()',
	shouldSwitchToArray: node => node.arguments[0].type !== 'SpreadElement',
};

// - `[].concat.apply([], array)` and `Array.prototype.concat.apply([], array)`
// - `[].concat.call([], maybeArray)` and `Array.prototype.concat.call([], maybeArray)`
// - `[].concat.call([], ...array)` and `Array.prototype.concat.call([], ...array)`
const arrayPrototypeConcat = {
	testFunction(node) {
		if (!(
			isMethodCall(node, {
				methods: ['apply', 'call'],
				argumentsLength: 2,
				allowSpreadElement: true,
				optionalCall: false,
				optionalMember: false,
			})
			&& isArrayPrototypeProperty(node.callee.object, {
				property: 'concat',
			})
		)) {
			return false;
		}

		const [firstArgument, secondArgument] = node.arguments;
		return isEmptyArrayExpression(firstArgument)
			&& (
				node.callee.property.name === 'call'
				|| secondArgument.type !== 'SpreadElement'
			);
	},
	getArrayNode(node) {
		const argumentNode = node.arguments[1];
		return argumentNode.type === 'SpreadElement' ? argumentNode.argument : argumentNode;
	},
	description: 'Array.prototype.concat()',
	shouldSwitchToArray: node => node.arguments[1].type !== 'SpreadElement' && node.callee.property.name === 'call',
};

const lodashFlattenFunctions = [
	'_.flatten',
	'lodash.flatten',
	'underscore.flatten',
];

function fix(node, array, context, shouldSwitchToArray, optional) {
	if (typeof shouldSwitchToArray === 'function') {
		shouldSwitchToArray = shouldSwitchToArray(node);
	}

	return function * (fixer) {
		const {sourceCode} = context;
		let fixed = getParenthesizedText(array, context);
		if (shouldSwitchToArray) {
			// `array` is an argument, when it changes to `array[]`, we don't need add extra parentheses
			fixed = `[${fixed}]`;
			// And we don't need to add parentheses to the new array to call `.flat()`
		} else if (
			!isParenthesized(array, sourceCode)
			&& shouldAddParenthesesToMemberExpressionObject(array, context)
		) {
			fixed = `(${fixed})`;
		}

		fixed = `${fixed}${optional ? '?' : ''}.flat()`;

		const tokenBefore = sourceCode.getTokenBefore(node);
		if (needsSemicolon(tokenBefore, context, fixed)) {
			fixed = `;${fixed}`;
		}

		yield fixer.replaceText(node, fixed);

		yield fixSpaceAroundKeyword(fixer, node, context);
	};
}

function create(context) {
	const {functions: configFunctions} = context.options[0];
	const functions = [...configFunctions, ...lodashFlattenFunctions];

	const cases = [
		arrayFlatMap,
		arrayReduce,
		emptyArrayConcat,
		arrayPrototypeConcat,
		{
			testFunction: node => isCallExpression(node, {
				argumentsLength: 1,
				optional: false,
			}) && isNodeMatches(node.callee, functions),
			getArrayNode: node => node.arguments[0],
			description: node => `${functions.find(nameOrPath => isNodeMatchesNameOrPath(node.callee, nameOrPath)).trim()}()`,
		},
	];

	context.on('CallExpression', function * (node) {
		for (const {testFunction, description, getArrayNode, shouldSwitchToArray, isOptionalArray} of cases) {
			if (!testFunction(node)) {
				continue;
			}

			const array = getArrayNode(node);
			const optional = isOptionalArray?.(node);

			const data = {
				description: typeof description === 'string' ? description : description(node),
			};

			const problem = {
				node,
				messageId: MESSAGE_ID,
				data,
			};

			const {sourceCode} = context;

			// Don't fix if it has comments.
			if (
				sourceCode.getCommentsInside(node).length
				=== sourceCode.getCommentsInside(array).length
			) {
				problem.fix = fix(node, array, context, shouldSwitchToArray, optional);
			}

			yield problem;
		}
	});
}

const schema = [
	{
		type: 'object',
		additionalProperties: false,
		properties: {
			functions: {
				type: 'array',
				uniqueItems: true,
			},
		},
	},
];

/** @type {import('eslint').Rule.RuleModule} */
const config = {
	create,
	meta: {
		type: 'suggestion',
		docs: {
			description: 'Prefer `Array#flat()` over legacy techniques to flatten arrays.',
			recommended: 'unopinionated',
		},
		fixable: 'code',
		schema,
		defaultOptions: [{functions: []}],
		messages,
	},
};

export default config;
