import {isCommentToken} from '@eslint-community/eslint-utils';
import {
	isParenthesized,
	getParenthesizedText,
	getParenthesizedRange,
	shouldAddParenthesesToLogicalExpressionChild,
} from './utils/index.js';

const MESSAGE_ID = 'prefer-simple-condition-first';
const MESSAGE_ID_SUGGESTION = 'prefer-simple-condition-first/suggestion';

const messages = {
	[MESSAGE_ID]: 'Prefer simple condition first in `{{operator}}` expression.',
	[MESSAGE_ID_SUGGESTION]: 'Swap conditions.',
};

/**
Check if a node is a "simple" condition:
1. Bare identifier (`foo`)
2. A binary `===`/`!==` where each operand is an identifier, a literal, or a signed
   numeric literal (`-1`, `+0`), and at least one operand is an identifier.
*/
function isSimpleOperand(node) {
	if (node.type === 'Identifier' || node.type === 'Literal') {
		return true;
	}

	// Negative/positive numeric literals: `-1`, `+0`
	return node.type === 'UnaryExpression'
		&& (node.operator === '-' || node.operator === '+')
		&& node.argument.type === 'Literal'
		&& typeof node.argument.value === 'number';
}

function isSimple(node) {
	if (node.type === 'Identifier') {
		return true;
	}

	if (
		node.type === 'UnaryExpression'
		&& node.operator === '!'
	) {
		return isSimple(node.argument);
	}

	if (
		node.type === 'BinaryExpression'
		&& (node.operator === '===' || node.operator === '!==')
	) {
		return isSimpleOperand(node.left) && isSimpleOperand(node.right)
			&& (node.left.type === 'Identifier' || node.right.type === 'Identifier');
	}

	// A chain of all-simple conditions is considered simple to prevent fix oscillation
	if (node.type === 'LogicalExpression') {
		return isSimple(node.left) && isSimple(node.right);
	}

	return false;
}

const sideEffectTypes = new Set([
	'AssignmentExpression',
	'UpdateExpression',
	'TaggedTemplateExpression',
	'AwaitExpression',
	'YieldExpression',
	'ImportExpression',
]);

/**
Check if an AST subtree contains side effects or throwing potential
(assignments, updates, member access, tagged templates, in/instanceof, await, yield, dynamic import).
These patterns are not flagged, since reordering would change program behavior.
*/
function hasSideEffectsOrThrows(node) {
	if (!node || typeof node !== 'object') {
		return false;
	}

	if (
		sideEffectTypes.has(node.type)
		// Property reads can throw or trigger getters, including with optional chaining.
		|| node.type === 'MemberExpression'
		// `in` and `instanceof` throw if the right operand is not an object/constructor
		|| (node.type === 'BinaryExpression' && (node.operator === 'in' || node.operator === 'instanceof'))
	) {
		return true;
	}

	for (const key of Object.keys(node)) {
		if (key === 'parent') {
			continue;
		}

		const value = node[key];
		if (Array.isArray(value)) {
			if (value.some(child => hasSideEffectsOrThrows(child))) {
				return true;
			}
		} else if (value && typeof value.type === 'string' && hasSideEffectsOrThrows(value)) {
			return true;
		}
	}

	return false;
}

/**
Check if an AST subtree contains call or new expressions.
*/
function hasCallOrNew(node) {
	if (!node || typeof node !== 'object') {
		return false;
	}

	if (node.type === 'CallExpression' || node.type === 'NewExpression') {
		return true;
	}

	for (const key of Object.keys(node)) {
		if (key === 'parent') {
			continue;
		}

		const value = node[key];
		if (Array.isArray(value)) {
			if (value.some(child => hasCallOrNew(child))) {
				return true;
			}
		} else if (value && typeof value.type === 'string' && hasCallOrNew(value)) {
			return true;
		}
	}

	return false;
}

function getSwapText(node, context, {operator, property}) {
	const isNodeParenthesized = isParenthesized(node, context);
	let text = isNodeParenthesized
		? getParenthesizedText(node, context)
		: context.sourceCode.getText(node);

	if (
		!isNodeParenthesized
		&& shouldAddParenthesesToLogicalExpressionChild(node, {operator, property})
	) {
		text = `(${text})`;
	}

	return text;
}

/**
Check if a LogicalExpression is used in a boolean context where the
produced value is only tested for truthiness, not consumed as a value.
*/
function isBooleanContext(node) {
	const {parent} = node;

	if (!parent) {
		return false;
	}

	if (
		(parent.type === 'IfStatement' && parent.test === node)
		|| (parent.type === 'WhileStatement' && parent.test === node)
		|| (parent.type === 'DoWhileStatement' && parent.test === node)
		|| (parent.type === 'ForStatement' && parent.test === node)
		|| (parent.type === 'ConditionalExpression' && parent.test === node)
		|| (parent.type === 'UnaryExpression' && parent.operator === '!')
	) {
		return true;
	}

	// A LogicalExpression nested inside another LogicalExpression inherits its context
	if (parent.type === 'LogicalExpression') {
		return isBooleanContext(parent);
	}

	return false;
}

function hasCommentsBetweenOperands(node, sourceCode) {
	return sourceCode.getTokensBetween(node.left, node.right, {includeComments: true})
		.some(token => isCommentToken(token));
}

function isReorderableLeftOperand(node) {
	return node.type === 'ConditionalExpression';
}

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

	context.on('LogicalExpression', node => {
		if (node.operator !== '&&' && node.operator !== '||') {
			return;
		}

		if (!isSimple(node.right) || isSimple(node.left)) {
			return;
		}

		if (!isReorderableLeftOperand(node.left)) {
			return;
		}

		// Only flag in boolean contexts — reordering in value-producing contexts changes the result
		if (!isBooleanContext(node)) {
			return;
		}

		// Skip expressions with side effects or throwing potential entirely
		if (hasSideEffectsOrThrows(node.left)) {
			return;
		}

		// Calls and `new` are lazy under short-circuiting, so swapping is not semantics-preserving.
		if (hasCallOrNew(node.left)) {
			return;
		}

		const rightText = getSwapText(node.right, context, {operator: node.operator, property: 'left'});
		const leftText = getSwapText(node.left, context, {operator: node.operator, property: 'right'});

		const hasCommentsBetween = hasCommentsBetweenOperands(node, sourceCode);
		const fix = hasCommentsBetween
			? undefined
			: fixer => fixer.replaceTextRange(
				[getParenthesizedRange(node.left, context)[0], getParenthesizedRange(node.right, context)[1]],
				`${rightText} ${node.operator} ${leftText}`,
			);

		// Use suggestion (not auto-fix) for chains to avoid fix oscillation.
		const isChain = node.left.type === 'LogicalExpression' && node.left.operator === node.operator;
		if (isChain) {
			return {
				node,
				loc: sourceCode.getLoc(node.right),
				messageId: MESSAGE_ID,
				data: {operator: node.operator},
				...(fix && {
					suggest: [
						{
							messageId: MESSAGE_ID_SUGGESTION,
							fix,
						},
					],
				}),
			};
		}

		return {
			node,
			loc: sourceCode.getLoc(node.right),
			messageId: MESSAGE_ID,
			data: {operator: node.operator},
			...(fix && {fix}),
		};
	});
};

/** @type {import('eslint').Rule.RuleModule} */
const config = {
	create,
	meta: {
		type: 'suggestion',
		docs: {
			description: 'Prefer simple conditions first in logical expressions.',
			recommended: 'unopinionated',
		},
		fixable: 'code',
		hasSuggestions: true,
		messages,
	},
};

export default config;
