import {
	TokenNode,
	WhitespaceNode,
	isFunctionNode,
	isSimpleBlockNode,
	isTokenNode,
	isWhiteSpaceOrCommentNode,
	isWhitespaceNode,
	parseListOfComponentValues,
	stringify,
	walk,
} from '@csstools/css-parser-algorithms';
import {
	TokenType,
	isTokenComma,
	isTokenDelim,
	isTokenDimension,
	isTokenIdent,
	isTokenNumeric,
	isTokenOpenCurly,
	isTokenOpenParen,
	isTokenSemicolon,
	mutateIdent,
	mutateUnit,
	tokenize,
} from '@csstools/css-tokenizer';

import { declarationValueIndex } from '../../utils/nodeFieldIndices.mjs';
import getDeclarationValue from '../../utils/getDeclarationValue.mjs';
import { mathFunctions } from '../../reference/functions.mjs';
import report from '../../utils/report.mjs';
import ruleMessages from '../../utils/ruleMessages.mjs';
import setDeclarationValue from '../../utils/setDeclarationValue.mjs';
import validateOptions from '../../utils/validateOptions.mjs';

import { assert } from '../../utils/validateTypes.mjs';

const ruleName = 'function-calc-no-unspaced-operator';

const messages = ruleMessages(ruleName, {
	expectedBefore: (operator) => `Expected single space before "${operator}" operator`,
	expectedAfter: (operator) => `Expected single space after "${operator}" operator`,
});

const meta = {
	url: 'https://stylelint.io/user-guide/rules/function-calc-no-unspaced-operator',
	fixable: true,
};

const OPERATORS = new Set(['+', '-']);
const OPERATOR_REGEX = /[+-]/;

const MATH_FUNCS_REGEX_SOURCE = [...mathFunctions].join('|');
const FUNC_NAMES_REGEX = new RegExp(`^(?:${MATH_FUNCS_REGEX_SOURCE})$`, 'i');
const FUNC_CALLS_REGEX = new RegExp(`(?:${MATH_FUNCS_REGEX_SOURCE})\\(`, 'i');

const NEWLINE_REGEX = /\n|\r\n/;

/** @import { CommentNode, ComponentValue, ContainerNode } from '@csstools/css-parser-algorithms' */

/** @type {import('stylelint').CoreRules[ruleName]} */
const rule = (primary) => {
	return (root, result) => {
		const validOptions = validateOptions(result, ruleName, { actual: primary });

		if (!validOptions) return;

		/**
		 * @param {string} message
		 * @param {import('postcss').Node} node
		 * @param {number} index
		 * @param {string} operator
		 * @param {() => void} fix
		 */
		function complain(message, node, index, operator, fix) {
			const endIndex = index + operator.length;

			report({ message, node, index, endIndex, result, ruleName, fix });
		}

		root.walkDecls((decl) => {
			const value = getDeclarationValue(decl);

			if (!OPERATOR_REGEX.test(value)) return;

			if (!FUNC_CALLS_REGEX.test(value)) return;

			const nodes = tokenizeDeclarationValue(value);

			if (nodes.length === 0) return;

			const valueIndex = declarationValueIndex(decl);
			const fixDeclarationValue = () => setDeclarationValue(decl, stringify([nodes]));

			/**
			 * @param {ContainerNode} node
			 * @param {Operation} operation
			 * @param {'before' | 'after'} position
			 */
			function checkCompleteOperation(node, operation, position) {
				if (operation[position].some(isWhitespaceNode)) return;

				const messageKey = position === 'before' ? 'expectedBefore' : 'expectedAfter';

				complain(
					messages[messageKey](operation.operatorChar),
					decl,
					valueIndex + operation.operatorCharPosition,
					operation.operatorChar,
					() => {
						operation.insertWhitespace(node, position);
						fixDeclarationValue();
					},
				);
			}

			/**
			 * @param {ContainerNode} node
			 * @param {Operation} operation
			 */
			function checkOperationWithoutOperator(node, operation) {
				if (isTokenNode(operation.firstOperand)) {
					const token = operation.firstOperand.value;

					/**
					 * @param {string | undefined} operatorChar
					 * @param {() => void} mutator
					 * @returns {boolean}
					 */
					const complainToFirstOperand = (operatorChar, mutator) => {
						if (!(operatorChar && OPERATOR_REGEX.test(operatorChar))) return false;

						const [, , , endPos] = token;

						operation.completeMissingOperator(operatorChar, endPos, 'append');

						complain(
							messages.expectedBefore(operation.operatorChar),
							decl,
							valueIndex + operation.operatorCharPosition,
							operation.operatorChar,
							() => {
								operation.insertOperatorAfterFirstOperand(node);
								mutator();
								fixDeclarationValue();
							},
						);

						return true;
					};

					if (isTokenDimension(token)) {
						// E.g. '2px+' → ['2px', '+']
						const [, , , , { unit }] = token;
						const operatorChar = unit.at(-1);
						const newUnit = unit.slice(0, -1);

						if (complainToFirstOperand(operatorChar, () => mutateUnit(token, newUnit))) {
							return;
						}
					}

					if (isTokenIdent(token)) {
						// E.g. 'id+' → ['id', '+']
						const [, , , , { value: tokenValue }] = token;
						const operatorChar = tokenValue.at(-1);
						const newTokenValue = tokenValue.slice(0, -1);

						if (complainToFirstOperand(operatorChar, () => mutateIdent(token, newTokenValue))) {
							return;
						}
					}
				}

				if (isTokenNode(operation.secondOperand) && isTokenNumeric(operation.secondOperand.value)) {
					const token = operation.secondOperand.value;
					const [, , startPos, , { signCharacter: operatorChar }] = token;

					if (operatorChar && OPERATOR_REGEX.test(operatorChar)) {
						operation.completeMissingOperator(operatorChar, startPos, 'prepend');

						complain(
							messages.expectedAfter(operation.operatorChar),
							decl,
							valueIndex + operation.operatorCharPosition,
							operation.operatorChar,
							() => {
								operation.insertOperatorBeforeSecondOperand(node);

								// Remove an operator character from the second operand token
								token[4].signCharacter = undefined;
								token[1] = token[1].slice(1);

								fixDeclarationValue();
							},
						);
					}
				}
			}

			/**
			 * @param {Operation} operation
			 * @param {'before' | 'after'} position
			 */
			function checkOperandWhitespace(operation, position) {
				operation[position].forEach((whitespaceNode) => {
					if (!isWhitespaceNode(whitespaceNode)) return;

					const whitespace = whitespaceNode.toString();

					if (whitespace === ' ') return;

					const indexOfFirstNewLine = whitespace.search(NEWLINE_REGEX);

					if (indexOfFirstNewLine === 0) return;

					const message = position === 'before' ? messages.expectedBefore : messages.expectedAfter;

					complain(
						message(operation.operatorChar),
						decl,
						valueIndex + operation.operatorCharPosition,
						operation.operatorChar,
						() => {
							whitespaceNode.value = newWhitespaceNode(
								indexOfFirstNewLine === -1 ? ' ' : whitespace.slice(indexOfFirstNewLine),
							).value;
							fixDeclarationValue();
						},
					);
				});
			}

			walk(
				nodes,
				({ node, state }) => {
					if (!state) return;

					// Step 2
					// Make sure that we are in a math context.
					// Once in a math context we remain in one until we encounter a non-math function.
					// Simple blocks with parentheses are the same as `calc()`
					if (isFunctionNode(node)) {
						state.inMathFunction = FUNC_NAMES_REGEX.test(node.getName().toLowerCase());
					} else if (!isSimpleBlockNode(node) || !isTokenOpenParen(node.startToken)) {
						state.inMathFunction = false;

						return;
					}

					if (!state.inMathFunction) return;

					let cursor = 0;
					/** @type {Operation | undefined} */
					let operation = undefined;

					while (cursor !== -1 && cursor < node.value.length) {
						// Step 3
						// Parse into operations
						// Each operation consumes as much whitespace before and after
						// Each parse call tries to consume as much as possible up to the next comma or semicolon
						// Operations consist of
						// - first operand
						// - operator
						// - second operand
						// - whitespace before and after
						[operation, cursor] = parseOperation(node, cursor);

						if (!operation) {
							cursor++;
							continue;
						}

						// Step 4
						// If there is no operator, try to find one
						if (!operation.operator) {
							checkOperationWithoutOperator(node, operation);
						}

						// Step 5
						// If the operation is complete, ensure there is whitespace around the operator
						// The operation might have started without an operator and might have been repaired by Step 4
						if (operation.operator) {
							checkCompleteOperation(node, operation, 'before');
							checkCompleteOperation(node, operation, 'after');

							// Step 6
							// Normalize the whitespace around the operands
							checkOperandWhitespace(operation, 'before');
							checkOperandWhitespace(operation, 'after');
						}

						cursor = node.value.indexOf(operation.secondOperand);
					}
				},
				{
					inMathFunction: false,
				},
			);
		});
	};
};

/**
 * @param {string} value
 * @returns {Array<ComponentValue>}
 */
function tokenizeDeclarationValue(value) {
	const tokens = tokenize({ css: value });

	// Step 1
	// Step 1.1
	// Re-tokenize dimensions with units containing dashes.
	// These might be typo's.
	// For example: `10px-20px` has a unit of `px-20px`
	tokens.forEach((token, i) => {
		if (!isTokenDimension(token)) return;

		const { unit } = token[4];

		if (unit.startsWith('--')) return;

		const indexOfDash = unit.indexOf('-');

		if (indexOfDash === -1) return;

		const remainder = unit.slice(indexOfDash);

		if (remainder.length === 1) return;

		mutateUnit(token, unit.slice(0, indexOfDash));
		token[3] = token[2] + token[1].length;

		const remainderTokens = tokenize({ css: remainder }).slice(0, -1); // Trim EOF token

		remainderTokens.forEach((remainderToken) => {
			remainderToken[2] += token[3];
			remainderToken[3] += token[3];
		});

		tokens.splice(i + 1, 0, ...remainderTokens);
	});

	// Step 1.2
	// Re-tokenize scss interpolation blocks
	// Grouping `#` and `{` into a single token allows us to parse these as simple blocks with curly braces.
	// For example: `#{$foo}`
	tokens.forEach((currentToken, i) => {
		if (!isTokenDelim(currentToken) || currentToken[4].value !== '#') return;

		const nextToken = tokens[i + 1];

		if (!isTokenOpenCurly(nextToken)) return;

		const nextNextToken = tokens[i + 2];

		if (!isTokenDelim(nextNextToken) || nextNextToken[4].value !== '$') return;

		// Set the string representation of the open curly to `#{`
		nextToken[1] = '#{';
		// Remove the `#` token
		tokens.splice(i, 1);
	});

	return parseListOfComponentValues(tokens);
}

/** @see https://drafts.csswg.org/css-values/#typedef-calc-value */
const OPERAND_TOKEN_TYPES = new Set([
	TokenType.Number,
	TokenType.Dimension,
	TokenType.Percentage,
	TokenType.Ident,
]);

/**
 * @param {ComponentValue | undefined} node
 * @returns {boolean}
 */
function isOperandNode(node) {
	if (isSimpleBlockNode(node)) return true;

	if (isFunctionNode(node)) {
		const name = node.getName().toLowerCase();

		if (mathFunctions.has(name) || name === 'var') return true;

		return false;
	}

	if (!isTokenNode(node)) return false;

	return OPERAND_TOKEN_TYPES.has(node.value[0]);
}

/**
 * @param {string} whitespace
 */
function newWhitespaceNode(whitespace = ' ') {
	return new WhitespaceNode([[TokenType.Whitespace, whitespace, -1, -1, undefined]]);
}

class Operation {
	/**
	 * @param {ComponentValue} firstOperand
	 * @param {Array<WhitespaceNode | CommentNode>} before
	 * @param {ComponentValue} secondOperand
	 * @param {Array<WhitespaceNode | CommentNode>} after
	 * @param {TokenNode | undefined} operator
	 */
	constructor(firstOperand, before, secondOperand, after, operator) {
		/** @type {typeof firstOperand} */
		this.firstOperand = firstOperand;
		/** @type {typeof before} */
		this.before = before;
		/** @type {typeof secondOperand} */
		this.secondOperand = secondOperand;
		/** @type {typeof after} */
		this.after = after;
		/** @type {typeof operator} */
		this.operator = operator;
	}

	get #operatorToken() {
		assert(isTokenDelim(this.operator?.value));

		return this.operator.value;
	}

	/** @returns {string} */
	get operatorChar() {
		return this.#operatorToken[4].value;
	}

	/** @returns {number} */
	get operatorCharPosition() {
		return this.#operatorToken[2];
	}

	/**
	 * @param {ContainerNode} node
	 * @param {'before' | 'after'} position
	 */
	insertWhitespace(node, position) {
		assert(this.operator);
		node.value.splice(
			node.value.indexOf(this.operator) + (position === 'before' ? 0 : 1),
			0,
			newWhitespaceNode(),
		);
	}

	/**
	 * @param {ContainerNode} node
	 */
	insertOperatorAfterFirstOperand(node) {
		assert(this.operator);
		node.value.splice(node.value.indexOf(this.firstOperand) + 1, 0, ...this.before, this.operator);
	}

	/**
	 * @param {ContainerNode} node
	 */
	insertOperatorBeforeSecondOperand(node) {
		assert(this.operator);
		node.value.splice(node.value.indexOf(this.secondOperand), 0, this.operator, ...this.after);
	}

	/**
	 * @param {string} operatorChar
	 * @param {number} operatorCharPosition
	 * @param {'append' | 'prepend'} type
	 */
	completeMissingOperator(operatorChar, operatorCharPosition, type) {
		this.operator = new TokenNode([
			TokenType.Delim,
			operatorChar,
			operatorCharPosition,
			operatorCharPosition + operatorChar.length,
			{ value: operatorChar },
		]);

		if (type === 'append') {
			this.after = this.before;
			this.before = [newWhitespaceNode()];
		} else {
			this.after = [newWhitespaceNode()];
		}
	}
}

/**
 * @param {ContainerNode} container
 * @param {number} cursor
 * @returns {[Operation | undefined, number]}
 */
function parseOperation(container, cursor) {
	let firstOperand = undefined;
	let secondOperand = undefined;
	const before = [];
	const after = [];
	let operator = undefined;

	let currentNode = container.value[cursor];

	// Consume as much whitespace and comments as possible
	while (isWhiteSpaceOrCommentNode(currentNode)) {
		currentNode = container.value[++cursor];
	}

	// If the current node is an operand, consume it
	if (isOperandNode(currentNode)) {
		firstOperand = currentNode;

		currentNode = container.value[++cursor];
	}

	// Consume as much whitespace and comments as possible
	// Assign to `before`
	while (isWhiteSpaceOrCommentNode(currentNode)) {
		before.push(currentNode);

		currentNode = container.value[++cursor];
	}

	// If the current node is an operator, consume it
	if (
		isTokenNode(currentNode) &&
		isTokenDelim(currentNode.value) &&
		OPERATORS.has(currentNode.value[4].value)
	) {
		operator = currentNode;

		currentNode = container.value[++cursor];
	}

	// Consume as much whitespace and comments as possible
	// Assign to `after`
	while (isWhiteSpaceOrCommentNode(currentNode)) {
		after.push(currentNode);

		currentNode = container.value[++cursor];
	}

	// If the current node is an operand, consume it
	if (isOperandNode(currentNode)) {
		secondOperand = currentNode;

		currentNode = container.value[++cursor];
	}

	// Consume as much whitespace and comments as possible
	while (isWhiteSpaceOrCommentNode(currentNode)) {
		currentNode = container.value[++cursor];
	}

	// If we have not consumed any operands, we are not in an operation
	// Do error recovery by consuming until the next comma or semicolon
	// If no comma or semicolon is found, consume until the end of the container
	if (!firstOperand || !secondOperand) {
		while (currentNode) {
			if (
				isTokenNode(currentNode) &&
				(isTokenComma(currentNode.value) || isTokenSemicolon(currentNode.value))
			) {
				return [undefined, cursor];
			}

			currentNode = container.value[++cursor];
		}

		return [undefined, container.value.length];
	}

	return [new Operation(firstOperand, before, secondOperand, after, operator), cursor];
}

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
export default rule;
