/** @import { Visitors } from 'zimmerframe' */
/** @import { AST, ValidatedCompileOptions } from '#compiler' */
/** @import { ComponentAnalysis } from '../../types.js' */
import MagicString from 'magic-string';
import { walk } from 'zimmerframe';
import { is_keyframes_node, regex_css_name_boundary, remove_css_prefix } from '../../css.js';
import { merge_with_preprocessor_map } from '../../../utils/mapped_code.js';
import { dev } from '../../../state.js';

/**
 * @typedef {{
 *   code: MagicString;
 *   hash: string;
 *   minify: boolean;
 *   selector: string;
 *   keyframes: string[];
 *   specificity: {
 *     bumped: boolean
 *   }
 * }} State
 */

/**
 *
 * @param {string} source
 * @param {ComponentAnalysis} analysis
 * @param {ValidatedCompileOptions} options
 */
export function render_stylesheet(source, analysis, options) {
	const code = new MagicString(source);

	/** @type {State} */
	const state = {
		code,
		hash: analysis.css.hash,
		minify: analysis.inject_styles && !options.dev,
		selector: `.${analysis.css.hash}`,
		keyframes: analysis.css.keyframes,
		specificity: {
			bumped: false
		}
	};

	const ast = /** @type {AST.CSS.StyleSheet} */ (analysis.css.ast);

	walk(/** @type {AST.CSS.Node} */ (ast), state, visitors);

	code.remove(0, ast.content.start);
	code.remove(/** @type {number} */ (ast.content.end), source.length);
	if (state.minify) {
		remove_preceding_whitespace(ast.content.end, state);
	}

	const css = {
		code: code.toString(),
		map: code.generateMap({
			// include source content; makes it easier/more robust looking up the source map code
			includeContent: true,
			// generateMap takes care of calculating source relative to file
			source: options.filename,
			file: options.cssOutputFilename || options.filename
		}),
		hasGlobal: analysis.css.has_global
	};

	merge_with_preprocessor_map(css, options, css.map.sources[0]);

	if (dev && analysis.inject_styles && css.code) {
		css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`;
	}

	return css;
}

/** @type {Visitors<AST.CSS.Node, State>} */
const visitors = {
	_: (node, context) => {
		context.state.code.addSourcemapLocation(node.start);
		context.state.code.addSourcemapLocation(node.end);
		context.next();
	},
	Atrule(node, { state, next, path }) {
		if (is_keyframes_node(node)) {
			let start = node.start + node.name.length + 1;
			while (state.code.original[start] === ' ') start += 1;
			let end = start;
			while (state.code.original[end] !== '{' && state.code.original[end] !== ' ') end += 1;

			if (node.prelude.startsWith('-global-')) {
				state.code.remove(start, start + 8);
			} else if (!is_in_global_block(path)) {
				state.code.prependRight(start, `${state.hash}-`);
			}

			return; // don't transform anything within
		}

		next();
	},
	Declaration(node, { state }) {
		const property = node.property && remove_css_prefix(node.property.toLowerCase());
		if (property === 'animation' || property === 'animation-name') {
			let index = node.start + node.property.length + 1;
			let name = '';

			while (index < state.code.original.length) {
				const character = state.code.original[index];

				if (regex_css_name_boundary.test(character)) {
					if (state.keyframes.includes(name)) {
						state.code.prependRight(index - name.length, `${state.hash}-`);
					}

					if (character === ';' || character === '}') {
						break;
					}

					name = '';
				} else {
					name += character;
				}

				index++;
			}
		} else if (state.minify) {
			remove_preceding_whitespace(node.start, state);

			// Don't minify whitespace in custom properties, since some browsers (Chromium < 99)
			// treat --foo: ; and --foo:; differently
			if (!node.property.startsWith('--')) {
				let start = node.start + node.property.length + 1;
				let end = start;
				while (/\s/.test(state.code.original[end])) end++;
				if (end > start) state.code.remove(start, end);
			}
		}
	},
	Rule(node, { state, next, visit, path }) {
		if (state.minify) {
			remove_preceding_whitespace(node.start, state);
			remove_preceding_whitespace(node.block.end - 1, state);
		}

		// keep empty rules in dev, because it's convenient to
		// see them in devtools
		if (!dev && is_empty(node, is_in_global_block(path))) {
			if (state.minify) {
				state.code.remove(node.start, node.end);
			} else {
				state.code.prependRight(node.start, '/* (empty) ');
				state.code.appendLeft(node.end, '*/');
				escape_comment_close(node, state.code);
			}

			return;
		}

		if (!is_used(node) && !is_in_global_block(path)) {
			if (state.minify) {
				state.code.remove(node.start, node.end);
			} else {
				state.code.prependRight(node.start, '/* (unused) ');
				state.code.appendLeft(node.end, '*/');
				escape_comment_close(node, state.code);
			}

			return;
		}

		if (node.metadata.is_global_block) {
			const selector = node.prelude.children[0];

			if (
				node.prelude.children.length === 1 &&
				selector.children.length === 1 &&
				selector.children[0].selectors.length === 1
			) {
				// `:global {...}`
				if (state.minify) {
					state.code.remove(node.start, node.block.start + 1);
					state.code.remove(node.block.end - 1, node.end);
				} else {
					state.code.prependRight(node.start, '/* ');
					state.code.appendLeft(node.block.start + 1, '*/');

					state.code.prependRight(node.block.end - 1, '/*');
					state.code.appendLeft(node.block.end, '*/');
				}

				// don't recurse into selectors but visit the body
				visit(node.block);
				return;
			}
		}

		next();
	},
	SelectorList(node, { state, next, path }) {
		const parent = path.at(-1);

		// Only add comments if we're not inside a complex selector that itself is unused or a global block
		if (
			(!is_in_global_block(path) ||
				(node.children.length > 1 && parent?.type === 'Rule' && parent.metadata.is_global_block)) &&
			!path.find((n) => n.type === 'ComplexSelector' && !n.metadata.used)
		) {
			const children = node.children;
			let pruning = false;
			let prune_start = children[0].start;
			let last = prune_start;
			let has_previous_used = false;

			for (let i = 0; i < children.length; i += 1) {
				const selector = children[i];

				if (selector.metadata.used === pruning) {
					if (pruning) {
						let i = selector.start;
						while (state.code.original[i] !== ',') i--;

						if (state.minify) {
							state.code.remove(prune_start, has_previous_used ? i : i + 1);
						} else {
							state.code.appendRight(has_previous_used ? i : i + 1, '*/');
						}
					} else {
						if (i === 0) {
							if (state.minify) {
								prune_start = selector.start;
							} else {
								state.code.prependRight(selector.start, '/* (unused) ');
							}
						} else {
							if (state.minify) {
								prune_start = last;
							} else {
								state.code.overwrite(last, selector.start, ` /* (unused) `);
							}
						}
					}

					pruning = !pruning;
				}

				if (!pruning && selector.metadata.used) {
					has_previous_used = true;
				}

				last = selector.end;
			}

			if (pruning) {
				if (state.minify) {
					state.code.remove(prune_start, last);
				} else {
					state.code.appendLeft(last, '*/');
				}
			}
		}

		// if we're in a `:is(...)` or whatever, keep existing specificity bump state
		let specificity = state.specificity;

		// if this selector list belongs to a rule, require a specificity bump for the
		// first scoped selector but only if we're at the top level
		if (parent?.type === 'Rule') {
			specificity = { bumped: false };

			/** @type {AST.CSS.Rule | null} */
			let rule = parent.metadata.parent_rule;

			while (rule) {
				if (rule.metadata.has_local_selectors) {
					specificity = { bumped: true };
					break;
				}
				rule = rule.metadata.parent_rule;
			}
		}

		next({ ...state, specificity });
	},
	ComplexSelector(node, context) {
		const before_bumped = context.state.specificity.bumped;

		for (const relative_selector of node.children) {
			if (relative_selector.metadata.is_global) {
				const global = /** @type {AST.CSS.PseudoClassSelector} */ (relative_selector.selectors[0]);
				remove_global_pseudo_class(global, relative_selector.combinator, context.state);

				const parent_rule = node.metadata.rule?.metadata.parent_rule;
				if (parent_rule && global.args === null) {
					if (relative_selector.combinator === null) {
						// div { :global.x { ... } } becomes div { &.x { ... } }
						context.state.code.prependRight(global.start, '&');
					}

					// In case of multiple :global selectors in a selector list we gotta delete the comma, too, but only if
					// the next selector is used; if it's unused then the comma deletion happens as part of removal of that next selector
					if (
						parent_rule.prelude.children.length > 1 &&
						node.children.length === node.children.findIndex((s) => s === relative_selector) - 1
					) {
						const next_selector = parent_rule.prelude.children.find((s) => s.start > global.end);
						if (next_selector && next_selector.metadata.used) {
							context.state.code.update(global.end, next_selector.start, '');
						}
					}
				}
				continue;
			} else {
				// for any :global() or :global at the middle of compound selector
				for (const selector of relative_selector.selectors) {
					if (selector.type === 'PseudoClassSelector' && selector.name === 'global') {
						remove_global_pseudo_class(selector, null, context.state);
					}
				}
			}

			if (relative_selector.metadata.scoped) {
				if (relative_selector.selectors.length === 1) {
					// skip standalone :is/:where/& selectors
					const selector = relative_selector.selectors[0];
					if (
						selector.type === 'PseudoClassSelector' &&
						(selector.name === 'is' || selector.name === 'where')
					) {
						continue;
					}
				}

				if (relative_selector.selectors.some((s) => s.type === 'NestingSelector')) {
					continue;
				}

				// for the first occurrence, we use a classname selector, so that every
				// encapsulated selector gets a +0-1-0 specificity bump. thereafter,
				// we use a `:where` selector, which does not affect specificity
				let modifier = context.state.selector;
				if (context.state.specificity.bumped) modifier = `:where(${modifier})`;

				context.state.specificity.bumped = true;

				let i = relative_selector.selectors.length;
				while (i--) {
					const selector = relative_selector.selectors[i];

					if (
						selector.type === 'PseudoElementSelector' ||
						selector.type === 'PseudoClassSelector'
					) {
						if (selector.name !== 'root' && selector.name !== 'host') {
							if (i === 0) context.state.code.prependRight(selector.start, modifier);
						}
						continue;
					}

					if (selector.type === 'TypeSelector' && selector.name === '*') {
						context.state.code.update(selector.start, selector.end, modifier);
					} else {
						context.state.code.appendLeft(selector.end, modifier);
					}

					break;
				}
			}
		}

		context.next();

		context.state.specificity.bumped = before_bumped;
	},
	PseudoClassSelector(node, context) {
		if (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not') {
			context.next();
		}
	}
};

/**
 * @param {Array<AST.CSS.Node>} path
 */
function is_in_global_block(path) {
	return path.some((node) => node.type === 'Rule' && node.metadata.is_global_block);
}

/**
 * @param {AST.CSS.PseudoClassSelector} selector
 * @param {AST.CSS.Combinator | null} combinator
 * @param {State} state
 */
function remove_global_pseudo_class(selector, combinator, state) {
	if (selector.args === null) {
		let start = selector.start;
		if (combinator?.name === ' ') {
			// div :global.x becomes div.x
			while (/\s/.test(state.code.original[start - 1])) start--;
		}

		// update(...), not remove(...) because there could be a closing unused comment at the end
		state.code.update(start, selector.start + ':global'.length, '');
	} else {
		state.code
			.remove(selector.start, selector.start + ':global('.length)
			.remove(selector.end - 1, selector.end);
	}
}

/**
 * Walk backwards until we find a non-whitespace character
 * @param {number} end
 * @param {State} state
 */
function remove_preceding_whitespace(end, state) {
	let start = end;
	while (/\s/.test(state.code.original[start - 1])) start--;
	if (start < end) state.code.remove(start, end);
}

/**
 *  @param {AST.CSS.Rule} rule
 * @param {boolean} is_in_global_block
 */
function is_empty(rule, is_in_global_block) {
	if (rule.metadata.is_global_block) {
		return rule.block.children.length === 0;
	}

	for (const child of rule.block.children) {
		if (child.type === 'Declaration') {
			return false;
		}

		if (child.type === 'Rule') {
			if ((is_used(child) || is_in_global_block) && !is_empty(child, is_in_global_block)) {
				return false;
			}
		}

		if (child.type === 'Atrule') {
			if (child.block === null || child.block.children.length > 0) return false;
		}
	}

	return true;
}

/** @param {AST.CSS.Rule} rule */
function is_used(rule) {
	return rule.prelude.children.some((selector) => selector.metadata.used);
}

/**
 *
 * @param {AST.CSS.Rule} node
 * @param {MagicString} code
 */
function escape_comment_close(node, code) {
	let escaped = false;
	let in_comment = false;

	for (let i = node.start; i < node.end; i++) {
		if (escaped) {
			escaped = false;
		} else {
			const char = code.original[i];
			if (in_comment) {
				if (char === '*' && code.original[i + 1] === '/') {
					code.prependRight(++i, '\\');
					in_comment = false;
				}
			} else if (char === '\\') {
				escaped = true;
			} else if (char === '/' && code.original[++i] === '*') {
				in_comment = true;
			}
		}
	}
}
