'use strict';

const beforeBlockString = require('../../utils/beforeBlockString');
const hasBlock = require('../../utils/hasBlock');
const optionsMatches = require('../../utils/optionsMatches');
const report = require('../../utils/report');
const ruleMessages = require('../../utils/ruleMessages');
const styleSearch = require('style-search');
const validateOptions = require('../../utils/validateOptions');
const { isAtRule, isDeclaration, isRoot, isRule } = require('../../utils/typeGuards');
const { isBoolean, isNumber, isString, assertString } = require('../../utils/validateTypes');

const ruleName = 'indentation';
const messages = ruleMessages(ruleName, {
	expected: (x) => `Expected indentation of ${x}`,
});

const meta = {
	url: 'https://stylelint.io/user-guide/rules/indentation',
	fixable: true,
};

/** @type {import('stylelint').Rule} */
const rule = (primary, secondaryOptions = {}, context) => {
	return (root, result) => {
		const validOptions = validateOptions(
			result,
			ruleName,
			{
				actual: primary,
				possible: [isNumber, 'tab'],
			},
			{
				actual: secondaryOptions,
				possible: {
					baseIndentLevel: [isNumber, 'auto'],
					except: ['block', 'value', 'param'],
					ignore: ['value', 'param', 'inside-parens'],
					indentInsideParens: ['twice', 'once-at-root-twice-in-block'],
					indentClosingBrace: [isBoolean],
				},
				optional: true,
			},
		);

		if (!validOptions) {
			return;
		}

		const spaceCount = isNumber(primary) ? primary : null;
		const indentChar = spaceCount == null ? '\t' : ' '.repeat(spaceCount);
		const warningWord = primary === 'tab' ? 'tab' : 'space';

		/** @type {number | 'auto'} */
		const baseIndentLevel = secondaryOptions.baseIndentLevel;
		/** @type {boolean} */
		const indentClosingBrace = secondaryOptions.indentClosingBrace;

		/**
		 * @param {number} level
		 */
		const legibleExpectation = (level) => {
			const count = spaceCount == null ? level : level * spaceCount;
			const quantifiedWarningWord = count === 1 ? warningWord : `${warningWord}s`;

			return `${count} ${quantifiedWarningWord}`;
		};

		// Cycle through all nodes using walk.
		root.walk((node) => {
			if (isRoot(node)) {
				// Ignore nested template literals root in css-in-js lang
				return;
			}

			const nodeLevel = indentationLevel(node);

			// Cut out any * and _ hacks from `before`
			const before = (node.raws.before || '').replace(/[*_]$/, '');
			const after = typeof node.raws.after === 'string' ? node.raws.after : '';
			const parent = node.parent;

			if (!parent) throw new Error('A parent node must be present');

			const expectedOpeningBraceIndentation = indentChar.repeat(nodeLevel);

			// Only inspect the spaces before the node
			// if this is the first node in root
			// or there is a newline in the `before` string.
			// (If there is no newline before a node,
			// there is no "indentation" to check.)
			const isFirstChild = parent.type === 'root' && parent.first === node;
			const lastIndexOfNewline = before.lastIndexOf('\n');

			// Inspect whitespace in the `before` string that is
			// *after* the *last* newline character,
			// because anything besides that is not indentation for this node:
			// it is some other kind of separation, checked by some separate rule
			if (
				(lastIndexOfNewline !== -1 ||
					(isFirstChild &&
						(!getDocument(parent) ||
							(parent.raws.codeBefore && parent.raws.codeBefore.endsWith('\n'))))) &&
				before.slice(lastIndexOfNewline + 1) !== expectedOpeningBraceIndentation
			) {
				if (context.fix) {
					if (isFirstChild && isString(node.raws.before)) {
						node.raws.before = node.raws.before.replace(
							/^[ \t]*(?=\S|$)/,
							expectedOpeningBraceIndentation,
						);
					}

					node.raws.before = fixIndentation(node.raws.before, expectedOpeningBraceIndentation);
				} else {
					report({
						message: messages.expected(legibleExpectation(nodeLevel)),
						node,
						result,
						ruleName,
					});
				}
			}

			// Only blocks have the `after` string to check.
			// Only inspect `after` strings that start with a newline;
			// otherwise there's no indentation involved.
			// And check `indentClosingBrace` to see if it should be indented an extra level.
			const closingBraceLevel = indentClosingBrace ? nodeLevel + 1 : nodeLevel;
			const expectedClosingBraceIndentation = indentChar.repeat(closingBraceLevel);

			if (
				(isRule(node) || isAtRule(node)) &&
				hasBlock(node) &&
				after &&
				after.includes('\n') &&
				after.slice(after.lastIndexOf('\n') + 1) !== expectedClosingBraceIndentation
			) {
				if (context.fix) {
					node.raws.after = fixIndentation(node.raws.after, expectedClosingBraceIndentation);
				} else {
					report({
						message: messages.expected(legibleExpectation(closingBraceLevel)),
						node,
						index: node.toString().length - 1,
						result,
						ruleName,
					});
				}
			}

			// If this is a declaration, check the value
			if (isDeclaration(node)) {
				checkValue(node, nodeLevel);
			}

			// If this is a rule, check the selector
			if (isRule(node)) {
				checkSelector(node, nodeLevel);
			}

			// If this is an at rule, check the params
			if (isAtRule(node)) {
				checkAtRuleParams(node, nodeLevel);
			}
		});

		/**
		 * @param {import('postcss').Node} node
		 * @param {number} level
		 * @returns {number}
		 */
		function indentationLevel(node, level = 0) {
			if (!node.parent) throw new Error('A parent node must be present');

			if (isRoot(node.parent)) {
				return level + getRootBaseIndentLevel(node.parent, baseIndentLevel, primary);
			}

			let calculatedLevel;

			// Indentation level equals the ancestor nodes
			// separating this node from root; so recursively
			// run this operation
			calculatedLevel = indentationLevel(node.parent, level + 1);

			// If `secondaryOptions.except` includes "block",
			// blocks are taken down one from their calculated level
			// (all blocks are the same level as their parents)
			if (
				optionsMatches(secondaryOptions, 'except', 'block') &&
				(isRule(node) || isAtRule(node)) &&
				hasBlock(node)
			) {
				calculatedLevel--;
			}

			return calculatedLevel;
		}

		/**
		 * @param {import('postcss').Declaration} decl
		 * @param {number} declLevel
		 */
		function checkValue(decl, declLevel) {
			if (!decl.value.includes('\n')) {
				return;
			}

			if (optionsMatches(secondaryOptions, 'ignore', 'value')) {
				return;
			}

			const declString = decl.toString();
			const valueLevel = optionsMatches(secondaryOptions, 'except', 'value')
				? declLevel
				: declLevel + 1;

			checkMultilineBit(declString, valueLevel, decl);
		}

		/**
		 * @param {import('postcss').Rule} ruleNode
		 * @param {number} ruleLevel
		 */
		function checkSelector(ruleNode, ruleLevel) {
			const selector = ruleNode.selector;

			// Less mixins have params, and they should be indented extra
			// @ts-expect-error -- TS2339: Property 'params' does not exist on type 'Rule'.
			if (ruleNode.params) {
				ruleLevel += 1;
			}

			checkMultilineBit(selector, ruleLevel, ruleNode);
		}

		/**
		 * @param {import('postcss').AtRule} atRule
		 * @param {number} ruleLevel
		 */
		function checkAtRuleParams(atRule, ruleLevel) {
			if (optionsMatches(secondaryOptions, 'ignore', 'param')) {
				return;
			}

			// @nest and SCSS's @at-root rules should be treated like regular rules, not expected
			// to have their params (selectors) indented
			const paramLevel =
				optionsMatches(secondaryOptions, 'except', 'param') ||
				atRule.name === 'nest' ||
				atRule.name === 'at-root'
					? ruleLevel
					: ruleLevel + 1;

			checkMultilineBit(beforeBlockString(atRule).trim(), paramLevel, atRule);
		}

		/**
		 * @param {string} source
		 * @param {number} newlineIndentLevel
		 * @param {import('postcss').Node} node
		 */
		function checkMultilineBit(source, newlineIndentLevel, node) {
			if (!source.includes('\n')) {
				return;
			}

			// Data for current node fixing
			/** @type {Array<{ expectedIndentation: string, currentIndentation: string, startIndex: number }>} */
			const fixPositions = [];

			// `outsideParens` because function arguments and also non-standard parenthesized stuff like
			// Sass maps are ignored to allow for arbitrary indentation
			let parentheticalDepth = 0;

			const ignoreInsideParans = optionsMatches(secondaryOptions, 'ignore', 'inside-parens');

			styleSearch(
				{
					source,
					target: '\n',
					// @ts-expect-error -- The `outsideParens` option is unsupported. Why?
					outsideParens: ignoreInsideParans,
				},
				(match, matchCount) => {
					const precedesClosingParenthesis = /^[ \t]*\)/.test(source.slice(match.startIndex + 1));

					if (ignoreInsideParans && (precedesClosingParenthesis || match.insideParens)) {
						return;
					}

					let expectedIndentLevel = newlineIndentLevel;

					// Modififications for parenthetical content
					if (!ignoreInsideParans && match.insideParens) {
						// If the first match in is within parentheses, reduce the parenthesis penalty
						if (matchCount === 1) parentheticalDepth -= 1;

						// Account for windows line endings
						let newlineIndex = match.startIndex;

						if (source[match.startIndex - 1] === '\r') {
							newlineIndex--;
						}

						const followsOpeningParenthesis = /\([ \t]*$/.test(source.slice(0, newlineIndex));

						if (followsOpeningParenthesis) {
							parentheticalDepth += 1;
						}

						const followsOpeningBrace = /\{[ \t]*$/.test(source.slice(0, newlineIndex));

						if (followsOpeningBrace) {
							parentheticalDepth += 1;
						}

						const startingClosingBrace = /^[ \t]*\}/.test(source.slice(match.startIndex + 1));

						if (startingClosingBrace) {
							parentheticalDepth -= 1;
						}

						expectedIndentLevel += parentheticalDepth;

						// Past this point, adjustments to parentheticalDepth affect next line

						if (precedesClosingParenthesis) {
							parentheticalDepth -= 1;
						}

						switch (secondaryOptions.indentInsideParens) {
							case 'twice':
								if (!precedesClosingParenthesis || indentClosingBrace) {
									expectedIndentLevel += 1;
								}

								break;
							case 'once-at-root-twice-in-block':
								if (node.parent === node.root()) {
									if (precedesClosingParenthesis && !indentClosingBrace) {
										expectedIndentLevel -= 1;
									}

									break;
								}

								if (!precedesClosingParenthesis || indentClosingBrace) {
									expectedIndentLevel += 1;
								}

								break;
							default:
								if (precedesClosingParenthesis && !indentClosingBrace) {
									expectedIndentLevel -= 1;
								}
						}
					}

					// Starting at the index after the newline, we want to
					// check that the whitespace characters (excluding newlines) before the first
					// non-whitespace character equal the expected indentation
					const afterNewlineSpaceMatches = /^([ \t]*)\S/.exec(source.slice(match.startIndex + 1));

					if (!afterNewlineSpaceMatches) {
						return;
					}

					const afterNewlineSpace = afterNewlineSpaceMatches[1] || '';
					const expectedIndentation = indentChar.repeat(
						expectedIndentLevel > 0 ? expectedIndentLevel : 0,
					);

					if (afterNewlineSpace !== expectedIndentation) {
						if (context.fix) {
							// Adding fixes position in reverse order, because if we change indent in the beginning of the string it will break all following fixes for that string
							fixPositions.unshift({
								expectedIndentation,
								currentIndentation: afterNewlineSpace,
								startIndex: match.startIndex,
							});
						} else {
							report({
								message: messages.expected(legibleExpectation(expectedIndentLevel)),
								node,
								index: match.startIndex + afterNewlineSpace.length + 1,
								result,
								ruleName,
							});
						}
					}
				},
			);

			if (fixPositions.length) {
				if (isRule(node)) {
					for (const fixPosition of fixPositions) {
						node.selector = replaceIndentation(
							node.selector,
							fixPosition.currentIndentation,
							fixPosition.expectedIndentation,
							fixPosition.startIndex,
						);
					}
				}

				if (isDeclaration(node)) {
					const declProp = node.prop;
					const declBetween = node.raws.between;

					if (!isString(declBetween)) {
						throw new TypeError('The `between` property must be a string');
					}

					for (const fixPosition of fixPositions) {
						if (fixPosition.startIndex < declProp.length + declBetween.length) {
							node.raws.between = replaceIndentation(
								declBetween,
								fixPosition.currentIndentation,
								fixPosition.expectedIndentation,
								fixPosition.startIndex - declProp.length,
							);
						} else {
							node.value = replaceIndentation(
								node.value,
								fixPosition.currentIndentation,
								fixPosition.expectedIndentation,
								fixPosition.startIndex - declProp.length - declBetween.length,
							);
						}
					}
				}

				if (isAtRule(node)) {
					const atRuleName = node.name;
					const atRuleAfterName = node.raws.afterName;
					const atRuleParams = node.params;

					if (!isString(atRuleAfterName)) {
						throw new TypeError('The `afterName` property must be a string');
					}

					for (const fixPosition of fixPositions) {
						// 1 — it's a @ length
						if (fixPosition.startIndex < 1 + atRuleName.length + atRuleAfterName.length) {
							node.raws.afterName = replaceIndentation(
								atRuleAfterName,
								fixPosition.currentIndentation,
								fixPosition.expectedIndentation,
								fixPosition.startIndex - atRuleName.length - 1,
							);
						} else {
							node.params = replaceIndentation(
								atRuleParams,
								fixPosition.currentIndentation,
								fixPosition.expectedIndentation,
								fixPosition.startIndex - atRuleName.length - atRuleAfterName.length - 1,
							);
						}
					}
				}
			}
		}
	};
};

/**
 * @param {import('postcss').Root} root
 * @param {number | 'auto'} baseIndentLevel
 * @param {string} space
 * @returns {number}
 */
function getRootBaseIndentLevel(root, baseIndentLevel, space) {
	const document = getDocument(root);

	if (!document) {
		return 0;
	}

	if (!root.source) {
		throw new Error('The root node must have a source');
	}

	/** @type {import('postcss').Source & { baseIndentLevel?: number }} */
	const source = root.source;

	const indentLevel = source.baseIndentLevel;

	if (isNumber(indentLevel) && Number.isSafeInteger(indentLevel)) {
		return indentLevel;
	}

	const newIndentLevel = inferRootIndentLevel(root, baseIndentLevel, () =>
		inferDocIndentSize(document, space),
	);

	source.baseIndentLevel = newIndentLevel;

	return newIndentLevel;
}

/**
 * @param {import('postcss').Node} node
 */
function getDocument(node) {
	// @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Node'.
	const document = node.document;

	if (document) {
		return document;
	}

	const root = node.root();

	// @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Node'.
	return root && root.document;
}

/**
 * @param {import('postcss').Document} document
 * @param {string} space
 * returns {number}
 */
function inferDocIndentSize(document, space) {
	if (!document.source) throw new Error('The document node must have a source');

	/** @type {import('postcss').Source & { indentSize?: number }} */
	const docSource = document.source;

	let indentSize = docSource.indentSize;

	if (isNumber(indentSize) && Number.isSafeInteger(indentSize)) {
		return indentSize;
	}

	const source = document.source.input.css;
	const indents = source.match(/^ *(?=\S)/gm);

	if (indents) {
		/** @type {Map<number, number>} */
		const scores = new Map();
		let lastIndentSize = 0;
		let lastLeadingSpacesLength = 0;

		/**
		 * @param {number} leadingSpacesLength
		 */
		const vote = (leadingSpacesLength) => {
			if (leadingSpacesLength) {
				lastIndentSize = Math.abs(leadingSpacesLength - lastLeadingSpacesLength) || lastIndentSize;

				if (lastIndentSize > 1) {
					const score = scores.get(lastIndentSize);

					if (score) {
						scores.set(lastIndentSize, score + 1);
					} else {
						scores.set(lastIndentSize, 1);
					}
				}
			} else {
				lastIndentSize = 0;
			}

			lastLeadingSpacesLength = leadingSpacesLength;
		};

		for (const leadingSpaces of indents) {
			vote(leadingSpaces.length);
		}

		let bestScore = 0;

		for (const [indentSizeDate, score] of scores.entries()) {
			if (score > bestScore) {
				bestScore = score;
				indentSize = indentSizeDate;
			}
		}
	}

	indentSize =
		Number(indentSize) || (indents && indents[0] && indents[0].length) || Number(space) || 2;
	docSource.indentSize = indentSize;

	return indentSize;
}

/**
 * @param {import('postcss').Root} root
 * @param {number | 'auto'} baseIndentLevel
 * @param {() => number} indentSize
 * @returns {number}
 */
function inferRootIndentLevel(root, baseIndentLevel, indentSize) {
	/**
	 * @param {string} indent
	 */
	function getIndentLevel(indent) {
		const tabMatch = indent.match(/\t/g);
		const tabCount = tabMatch ? tabMatch.length : 0;

		const spaceMatch = indent.match(/ /g);
		const spaceCount = spaceMatch ? Math.round(spaceMatch.length / indentSize()) : 0;

		return tabCount + spaceCount;
	}

	let newBaseIndentLevel = 0;

	if (!isNumber(baseIndentLevel) || !Number.isSafeInteger(baseIndentLevel)) {
		if (!root.source) throw new Error('The root node must have a source');

		let source = root.source.input.css;

		source = source.replace(/^[^\r\n]+/, (firstLine) => {
			const match = root.raws.codeBefore && /(?:^|\n)([ \t]*)$/.exec(root.raws.codeBefore);

			if (match) {
				return match[1] + firstLine;
			}

			return '';
		});

		const indents = source.match(/^[ \t]*(?=\S)/gm);

		if (indents) {
			return Math.min(...indents.map((indent) => getIndentLevel(indent)));
		}

		newBaseIndentLevel = 1;
	} else {
		newBaseIndentLevel = baseIndentLevel;
	}

	const indents = [];
	const foundIndents = root.raws.codeBefore && /(?:^|\n)([ \t]*)\S/m.exec(root.raws.codeBefore);

	// The indent level of the CSS code block in non-CSS-like files is determined by the shortest indent of non-empty line.
	if (foundIndents) {
		let shortest = Number.MAX_SAFE_INTEGER;
		let i = 0;

		while (++i < foundIndents.length) {
			const foundIndent = foundIndents[i];

			assertString(foundIndent);
			const current = getIndentLevel(foundIndent);

			if (current < shortest) {
				shortest = current;

				if (shortest === 0) {
					break;
				}
			}
		}

		if (shortest !== Number.MAX_SAFE_INTEGER) {
			indents.push(new Array(shortest).fill(' ').join(''));
		}
	}

	const after = root.raws.after;

	if (after) {
		let afterEnd;

		if (after.endsWith('\n')) {
			// @ts-expect-error -- TS2339: Property 'document' does not exist on type 'Root'.
			const document = root.document;

			if (document) {
				const nextRoot = document.nodes[document.nodes.indexOf(root) + 1];

				afterEnd = nextRoot ? nextRoot.raws.codeBefore : document.raws.codeAfter;
			} else {
				// Nested root node in css-in-js lang
				const parent = root.parent;

				if (!parent) throw new Error('The root node must have a parent');

				const nextRoot = parent.nodes[parent.nodes.indexOf(root) + 1];

				afterEnd = nextRoot ? nextRoot.raws.codeBefore : root.raws.codeAfter;
			}
		} else {
			afterEnd = after;
		}

		if (afterEnd) indents.push(afterEnd.match(/^[ \t]*/)[0]);
	}

	if (indents.length) {
		return Math.max(...indents.map((indent) => getIndentLevel(indent))) + newBaseIndentLevel;
	}

	return newBaseIndentLevel;
}

/**
 * @param {string | undefined} str
 * @param {string} whitespace
 */
function fixIndentation(str, whitespace) {
	if (!isString(str)) {
		return str;
	}

	return str.replace(/\n[ \t]*(?=\S|$)/g, `\n${whitespace}`);
}

/**
 * @param {string} input
 * @param {string} searchString
 * @param {string} replaceString
 * @param {number} startIndex
 */
function replaceIndentation(input, searchString, replaceString, startIndex) {
	const offset = startIndex + 1;
	const stringStart = input.slice(0, offset);
	const stringEnd = input.slice(offset + searchString.length);

	return stringStart + replaceString + stringEnd;
}

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
module.exports = rule;
