import {isParenthesized, findVariable} from '@eslint-community/eslint-utils';
import {
	extendFixRange,
	removeMemberExpressionProperty,
	removeMethodCall,
	renameVariable,
} from './fix/index.js';
import {
	isLeftHandSide,
	singular,
	getScopes,
	getAvailableVariableName,
	getVariableIdentifiers,
} from './utils/index.js';
import {isMethodCall} from './ast/index.js';

const ERROR_ZERO_INDEX = 'error-zero-index';
const ERROR_SHIFT = 'error-shift';
const ERROR_POP = 'error-pop';
const ERROR_AT_ZERO = 'error-at-zero';
const ERROR_AT_MINUS_ONE = 'error-at-minus-one';
const ERROR_DESTRUCTURING_DECLARATION = 'error-destructuring-declaration';
const ERROR_DESTRUCTURING_ASSIGNMENT = 'error-destructuring-assignment';
const ERROR_DECLARATION = 'error-variable';
const SUGGESTION_NULLISH_COALESCING_OPERATOR = 'suggest-nullish-coalescing-operator';
const SUGGESTION_LOGICAL_OR_OPERATOR = 'suggest-logical-or-operator';
const messages = {
	[ERROR_DECLARATION]: 'Prefer `.find(…)` over `.filter(…)`.',
	[ERROR_ZERO_INDEX]: 'Prefer `.find(…)` over `.filter(…)[0]`.',
	[ERROR_AT_ZERO]: 'Prefer `.find(…)` over `.filter(…).at(0)`.',
	[ERROR_SHIFT]: 'Prefer `.find(…)` over `.filter(…).shift()`.',
	[ERROR_POP]: 'Prefer `.findLast(…)` over `.filter(…).pop()`.',
	[ERROR_AT_MINUS_ONE]: 'Prefer `.findLast(…)` over `.filter(…).at(-1)`.',
	[ERROR_DESTRUCTURING_DECLARATION]: 'Prefer `.find(…)` over destructuring `.filter(…)`.',
	// Same message as `ERROR_DESTRUCTURING_DECLARATION`, but different case
	[ERROR_DESTRUCTURING_ASSIGNMENT]: 'Prefer `.find(…)` over destructuring `.filter(…)`.',
	[SUGGESTION_NULLISH_COALESCING_OPERATOR]: 'Replace `.filter(…)` with `.find(…) ?? …`.',
	[SUGGESTION_LOGICAL_OR_OPERATOR]: 'Replace `.filter(…)` with `.find(…) || …`.',
};

const isArrayFilterCall = (node, options) => isMethodCall(node, {
	method: 'filter',
	minimumArguments: 1,
	maximumArguments: 2,
	optionalCall: false,
	...options,
});

// Need add `()` to the `AssignmentExpression`
// - `ObjectExpression`: `[{foo}] = array.filter(bar)` fix to `{foo} = array.find(bar)`
// - `ObjectPattern`: `[{foo = baz}] = array.filter(bar)`
const assignmentNeedParenthesize = (node, sourceCode) => {
	const isAssign = node.type === 'AssignmentExpression';

	if (!isAssign || isParenthesized(node, sourceCode)) {
		return false;
	}

	const {left} = getDestructuringLeftAndRight(node);
	const [element] = left.elements;
	const {type} = element.type === 'AssignmentPattern' ? element.left : element;
	return type === 'ObjectExpression' || type === 'ObjectPattern';
};

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table
const hasLowerPrecedence = (node, operator) => (
	(node.type === 'LogicalExpression' && (
		node.operator === operator
		// https://tc39.es/proposal-nullish-coalescing/ says
		// `??` has lower precedence than `||`
		// But MDN says
		// `??` has higher precedence than `||`
		|| (operator === '||' && node.operator === '??')
		|| (operator === '??' && (node.operator === '||' || node.operator === '&&'))
	))
	|| node.type === 'ConditionalExpression'
	// Lower than `assignment`, should already parenthesized
	/* c8 ignore next */
	|| node.type === 'AssignmentExpression'
	|| node.type === 'YieldExpression'
	|| node.type === 'SequenceExpression'
);

const getDestructuringLeftAndRight = node => {
	/* c8 ignore next 3 */
	if (!node) {
		return {};
	}

	if (node.type === 'AssignmentExpression') {
		return node;
	}

	if (node.type === 'VariableDeclarator') {
		return {left: node.id, right: node.init};
	}

	return {};
};

function * fixDestructuring(node, sourceCode, fixer) {
	const {left} = getDestructuringLeftAndRight(node);
	const [element] = left.elements;

	const leftText = sourceCode.getText(element.type === 'AssignmentPattern' ? element.left : element);
	yield fixer.replaceText(left, leftText);

	// `AssignmentExpression` always starts with `[` or `(`, so we don't need check ASI
	if (assignmentNeedParenthesize(node, sourceCode)) {
		yield fixer.insertTextBefore(node, '(');
		yield fixer.insertTextAfter(node, ')');
	}
}

const hasDefaultValue = node => getDestructuringLeftAndRight(node).left.elements[0].type === 'AssignmentPattern';

const fixDestructuringDefaultValue = (node, sourceCode, fixer, operator) => {
	const {left, right} = getDestructuringLeftAndRight(node);
	const [element] = left.elements;
	const defaultValue = element.right;
	let defaultValueText = sourceCode.getText(defaultValue);

	if (isParenthesized(defaultValue, sourceCode) || hasLowerPrecedence(defaultValue, operator)) {
		defaultValueText = `(${defaultValueText})`;
	}

	return fixer.insertTextAfter(right, ` ${operator} ${defaultValueText}`);
};

const fixDestructuringAndReplaceFilter = (sourceCode, node) => {
	const {property} = getDestructuringLeftAndRight(node).right.callee;

	let suggest;
	let fix;

	if (hasDefaultValue(node)) {
		suggest = [
			{operator: '??', messageId: SUGGESTION_NULLISH_COALESCING_OPERATOR},
			{operator: '||', messageId: SUGGESTION_LOGICAL_OR_OPERATOR},
		].map(({messageId, operator}) => ({
			messageId,
			* fix(fixer) {
				yield fixer.replaceText(property, 'find');
				yield fixDestructuringDefaultValue(node, sourceCode, fixer, operator);
				yield fixDestructuring(node, sourceCode, fixer);
			},
		}));
	} else {
		fix = function * (fixer) {
			yield fixer.replaceText(property, 'find');
			yield fixDestructuring(node, sourceCode, fixer);
		};
	}

	return {fix, suggest};
};

const isAccessingZeroIndex = node =>
	node.parent.type === 'MemberExpression'
	&& node.parent.computed === true
	&& node.parent.object === node
	&& node.parent.property.type === 'Literal'
	&& node.parent.property.raw === '0';

const isDestructuringFirstElement = node => {
	const {left, right} = getDestructuringLeftAndRight(node.parent);
	return left
		&& right
		&& right === node
		&& left.type === 'ArrayPattern'
		&& left.elements.length === 1
		&& left.elements[0]
		&& left.elements[0].type !== 'RestElement';
};

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

	// Zero index access
	// `array.filter()[0]`
	// `array?.filter()[0]`
	context.on('MemberExpression', node => {
		if (!(
			node.computed
			&& node.property.type === 'Literal'
			&& node.property.raw === '0'
			&& isArrayFilterCall(node.object)
			&& !isLeftHandSide(node)
		)) {
			return;
		}

		return {
			node: node.object.callee.property,
			messageId: ERROR_ZERO_INDEX,
			fix: fixer => [
				fixer.replaceText(node.object.callee.property, 'find'),
				removeMemberExpressionProperty(fixer, node, context),
			],
		};
	});

	// `array.filter().shift()`
	// `array?.filter().shift()`
	context.on('CallExpression', node => {
		if (!(
			isMethodCall(node, {
				method: 'shift',
				argumentsLength: 0,
				optionalCall: false,
				optionalMember: false,
			})
			&& isArrayFilterCall(node.callee.object)
		)) {
			return;
		}

		return {
			node: node.callee.object.callee.property,
			messageId: ERROR_SHIFT,
			fix: fixer => [
				fixer.replaceText(node.callee.object.callee.property, 'find'),
				...removeMethodCall(fixer, node, context),
			],
		};
	});

	// `const [foo] = array.filter()`
	context.on('VariableDeclarator', node => {
		if (!(
			node.id.type === 'ArrayPattern'
			&& node.id.elements.length === 1
			&& node.id.elements[0]
			&& node.id.elements[0].type !== 'RestElement'
			&& isArrayFilterCall(node.init, {optionalMember: false})
		)) {
			return;
		}

		return {
			node: node.init.callee.property,
			messageId: ERROR_DESTRUCTURING_DECLARATION,
			...fixDestructuringAndReplaceFilter(sourceCode, node),
		};
	});

	// `[foo] = array.filter()`
	context.on('AssignmentExpression', node => {
		if (!(
			node.left.type === 'ArrayPattern'
			&& node.left.elements.length === 1
			&& node.left.elements[0]
			&& node.left.elements[0].type !== 'RestElement'
			&& isArrayFilterCall(node.right, {optionalMember: false})
		)) {
			return;
		}

		return {
			node: node.right.callee.property,
			messageId: ERROR_DESTRUCTURING_ASSIGNMENT,
			...fixDestructuringAndReplaceFilter(sourceCode, node),
		};
	});

	// `const foo = array.filter(); foo[0]; [bar] = foo`
	context.on('VariableDeclarator', node => {
		if (!(
			node.id.type === 'Identifier'
			&& isArrayFilterCall(node.init, {optionalMember: false})
			&& node.parent.type === 'VariableDeclaration'
			&& node.parent.declarations.includes(node)
			// Exclude `export const foo = [];`
			&& !(
				node.parent.parent.type === 'ExportNamedDeclaration'
				&& node.parent.parent.declaration === node.parent
			)
		)) {
			return;
		}

		const scope = sourceCode.getScope(node);
		const variable = findVariable(scope, node.id);
		const identifiers = getVariableIdentifiers(variable).filter(identifier => identifier !== node.id);

		if (identifiers.length === 0) {
			return;
		}

		const zeroIndexNodes = [];
		const destructuringNodes = [];
		for (const identifier of identifiers) {
			if (isAccessingZeroIndex(identifier)) {
				zeroIndexNodes.push(identifier.parent);
			} else if (isDestructuringFirstElement(identifier)) {
				destructuringNodes.push(identifier.parent);
			} else {
				return;
			}
		}

		const problem = {
			node: node.init.callee.property,
			messageId: ERROR_DECLARATION,
		};

		// `const [foo = bar] = baz` is not fixable
		if (!destructuringNodes.some(node => hasDefaultValue(node))) {
			problem.fix = function * (fixer) {
				yield fixer.replaceText(node.init.callee.property, 'find');

				const singularName = singular(node.id.name);
				if (singularName) {
					// Rename variable to be singularized now that it refers to a single item in the array instead of the entire array.
					const singularizedName = getAvailableVariableName(singularName, getScopes(scope));
					yield renameVariable(variable, singularizedName, context, fixer);

					// Prevent possible variable conflicts
					yield extendFixRange(fixer, sourceCode.getRange(sourceCode.ast));
				}

				for (const node of zeroIndexNodes) {
					yield removeMemberExpressionProperty(fixer, node, context);
				}

				for (const node of destructuringNodes) {
					yield fixDestructuring(node, sourceCode, fixer);
				}
			};
		}

		return problem;
	});

	// `array.filter().at(0)`
	// `array?.filter().at(0)`
	context.on('CallExpression', node => {
		if (!(
			isMethodCall(node, {
				method: 'at',
				argumentsLength: 1,
				optionalCall: false,
				optionalMember: false,
			})
			&& node.arguments[0].type === 'Literal'
			&& node.arguments[0].raw === '0'
			&& isArrayFilterCall(node.callee.object)
		)) {
			return;
		}

		return {
			node: node.callee.object.callee.property,
			messageId: ERROR_AT_ZERO,
			fix: fixer => [
				fixer.replaceText(node.callee.object.callee.property, 'find'),
				...removeMethodCall(fixer, node, context),
			],
		};
	});

	if (!checkFromLast) {
		return;
	}

	// `array.filter().pop()`
	// `array?.filter().pop()`
	context.on('CallExpression', node => {
		if (!(
			isMethodCall(node, {
				method: 'pop',
				argumentsLength: 0,
				optionalCall: false,
				optionalMember: false,
			})
			&& isArrayFilterCall(node.callee.object)
		)) {
			return;
		}

		return {
			node: node.callee.object.callee.property,
			messageId: ERROR_POP,
			fix: fixer => [
				fixer.replaceText(node.callee.object.callee.property, 'findLast'),
				...removeMethodCall(fixer, node, context),
			],
		};
	});

	// `array.filter().at(-1)`
	// `array?.filter().at(-1)`
	context.on('CallExpression', node => {
		if (!(
			isMethodCall(node, {
				method: 'at',
				argumentsLength: 1,
				optionalCall: false,
				optionalMember: false,
			})
			&& node.arguments[0].type === 'UnaryExpression'
			&& node.arguments[0].operator === '-'
			&& node.arguments[0].prefix
			&& node.arguments[0].argument.type === 'Literal'
			&& node.arguments[0].argument.raw === '1'
			&& isArrayFilterCall(node.callee.object)
		)) {
			return;
		}

		return {
			node: node.callee.object.callee.property,
			messageId: ERROR_AT_MINUS_ONE,
			fix: fixer => [
				fixer.replaceText(node.callee.object.callee.property, 'findLast'),
				...removeMethodCall(fixer, node, context),
			],
		};
	});
};

const schema = [
	{
		type: 'object',
		additionalProperties: false,
		properties: {
			checkFromLast: {
				type: 'boolean',
			},
		},
	},
];

/** @type {import('eslint').Rule.RuleModule} */
const config = {
	create,
	meta: {
		type: 'suggestion',
		docs: {
			description: 'Prefer `.find(…)` and `.findLast(…)` over the first or last element from `.filter(…)`.',
			recommended: 'unopinionated',
		},
		fixable: 'code',
		hasSuggestions: true,
		schema,
		defaultOptions: [{checkFromLast: true}],
		messages,
	},
};

export default config;
