/*
	MIT License http://www.opensource.org/licenses/mit-license.php
*/

"use strict";

const vm = require("vm");
const Parser = require("../Parser");
const ConstDependency = require("../dependencies/ConstDependency");
const HtmlInlineScriptDependency = require("../dependencies/HtmlInlineScriptDependency");
const HtmlInlineStyleDependency = require("../dependencies/HtmlInlineStyleDependency");
const HtmlScriptSrcDependency = require("../dependencies/HtmlScriptSrcDependency");
const HtmlSourceDependency = require("../dependencies/HtmlSourceDependency");
const StaticExportsDependency = require("../dependencies/StaticExportsDependency");
const CommentCompilationWarning = require("../errors/CommentCompilationWarning");
const ModuleDependencyError = require("../errors/ModuleDependencyError");
const UnsupportedFeatureWarning = require("../errors/UnsupportedFeatureWarning");
const WebpackError = require("../errors/WebpackError");
const LocConverter = require("../util/LocConverter");
const createHash = require("../util/createHash");
const { contextify } = require("../util/identifier");
const {
	createMagicCommentContext,
	webpackCommentRegExp
} = require("../util/magicComment");
const walkHtmlTokens = require("./walkHtmlTokens");

/** @typedef {import("../Module").BuildInfo} BuildInfo */
/** @typedef {import("../Module").BuildMeta} BuildMeta */
/** @typedef {import("../Parser").ParserState} ParserState */
/** @typedef {import("../Parser").PreparsedAst} PreparsedAst */

const HORIZONTAL_TAB = "\u0009".charCodeAt(0);
const NEWLINE = "\u000A".charCodeAt(0);
const FORM_FEED = "\u000C".charCodeAt(0);
const CARRIAGE_RETURN = "\u000D".charCodeAt(0);
const SPACE = "\u0020".charCodeAt(0);
const COMMA = ",".charCodeAt(0);
const LEFT_PARENTHESIS = "(".charCodeAt(0);
const RIGHT_PARENTHESIS = ")".charCodeAt(0);
const SMALL_LETTER_W = "w".charCodeAt(0);
const SMALL_LETTER_X = "x".charCodeAt(0);
const SMALL_LETTER_H = "h".charCodeAt(0);

/**
 * @param {number} char char
 * @returns {boolean} true when ASCII whitespace, otherwise false
 */
function isASCIIWhitespace(char) {
	return (
		// Horizontal tab
		char === HORIZONTAL_TAB ||
		// New line
		char === NEWLINE ||
		// Form feed
		char === FORM_FEED ||
		// Carriage return
		char === CARRIAGE_RETURN ||
		// Space
		char === SPACE
	);
}

/** @typedef {[string, number, number]} ParsedSource */

// eslint-disable-next-line no-control-regex
const IGNORE_CHARS_REGEXP = /[\u0000-\u001F\u007F-\u009F\u00A0]/g;

/**
 * @param {string} input input
 * @returns {ParsedSource[]} parsed src
 */
const parseSrc = (input) => {
	const len = input.length;
	if (len === 0) throw new Error("Must be non-empty");

	let start = 0;
	let end = len;

	while (start < end) {
		const code = input.charCodeAt(start);
		if (code > 32 && code !== 160) break;
		start++;
	}

	if (start === end) throw new Error("Must be non-empty");

	while (end > start) {
		const code = input.charCodeAt(end - 1);
		if (code > 32 && code !== 160) break;
		end--;
	}

	let value = input.slice(start, end);

	if (IGNORE_CHARS_REGEXP.test(value)) {
		value = value.replace(IGNORE_CHARS_REGEXP, "");
		if (value.length === 0) throw new Error("Must be non-empty");
	}

	return [[value, start, end]];
};

// HTML `<style>` content is rawtext: it ends at the first `</style>`
// where the tag name is followed by whitespace, `>` or `/`. The
// lookahead (rather than a consuming character class) keeps the match
// from running past the first `>` into a later tag — the
// `[^>]*` only consumes any (rarely-seen) end-tag attributes before the
// closing `>` of the end tag itself.
const STYLE_END_REGEXP = /<\/style(?=[\s/>])[^>]*>/gi;

// (Don't use \s, to avoid matching non-breaking space)
// eslint-disable-next-line no-control-regex
const LEADING_SPACES_REGEXP = /^[ \t\n\r\u000C]+/;
// eslint-disable-next-line no-control-regex
const LEADING_COMMAS_OR_SPACES_REGEXP = /^[, \t\n\r\u000C]+/;
// eslint-disable-next-line no-control-regex
const LEADING_NOT_SPACES = /^[^ \t\n\r\u000C]+/;
const TRAILING_COMMAS_REGEXP = /[,]+$/;
const NON_NEGATIVE_INTEGER_REGEXP = /^\d+$/;
// ( Positive or negative or unsigned integers or decimals, without or without exponents.
// Must include at least one digit.
// According to spec tests any decimal point must be followed by a digit.
// No leading plus sign is allowed.)
// https://html.spec.whatwg.org/multipage/infrastructure.html#valid-floating-point-number
const FLOATING_POINT_REGEXP =
	/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;

/**
 * @param {string} input input
 * @returns {ParsedSource[]} parsed srcset
 */
const parseSrcset = (input) => {
	// 1. Let input be the value passed to this algorithm.
	const inputLength = input.length;

	/** @type {string | undefined} */
	let url;
	/** @type {string[]} */
	let descriptors;
	/** @type {string} */
	let currentDescriptor;
	/** @type {string} */
	let state;
	/** @type {number} */
	let charCode;
	/** @type {number} */
	let position = 0;
	/** @type {number} */
	let start;

	/** @type {[string, number, number][]} */
	const candidates = [];

	/**
	 * @param {RegExp} regExp reg exp to collect characters
	 * @returns {string | undefined} characters
	 */
	function collectCharacters(regExp) {
		/** @type {string} */
		let chars;
		const match = regExp.exec(input.slice(Math.max(0, position)));

		if (match) {
			[chars] = match;
			position += chars.length;

			return chars;
		}
	}

	/**
	 * @returns {void}
	 */
	function parseDescriptors() {
		// 9. Descriptor parser: Let error be no.
		let pError = false;

		// 10. Let width be absent.
		// 11. Let density be absent.
		// 12. Let future-compat-h be absent. (We're implementing it now as h)
		/** @type {number | undefined} */
		let width;
		/** @type {number | undefined} */
		let density;
		/** @type {number | undefined} */
		let height;
		/** @type {string | undefined} */
		let desc;

		// 13. For each descriptor in descriptors, run the appropriate set of steps
		// from the following list:
		for (let i = 0; i < descriptors.length; i++) {
			desc = descriptors[i];

			const lastChar = desc[desc.length - 1].charCodeAt(0);
			const value = desc.slice(0, Math.max(0, desc.length - 1));

			// If the descriptor consists of a valid non-negative integer followed by
			// a U+0077 LATIN SMALL LETTER W character
			if (
				NON_NEGATIVE_INTEGER_REGEXP.test(value) &&
				lastChar === SMALL_LETTER_W
			) {
				// If width and density are not both absent, then let error be yes.
				if (width || density) {
					pError = true;
				}

				const intVal = Number.parseInt(value, 10);

				// Apply the rules for parsing non-negative integers to the descriptor.
				// If the result is zero, let error be yes.
				// Otherwise, let width be the result.
				if (intVal === 0) {
					pError = true;
				} else {
					width = intVal;
				}
			}
			// If the descriptor consists of a valid floating-point number followed by
			// a U+0078 LATIN SMALL LETTER X character
			else if (
				FLOATING_POINT_REGEXP.test(value) &&
				lastChar === SMALL_LETTER_X
			) {
				// If width, density and future-compat-h are not all absent, then let error
				// be yes.
				if (width || density || height) {
					pError = true;
				}

				const floatVal = Number.parseFloat(value);

				// Apply the rules for parsing floating-point number values to the descriptor.
				// If the result is less than zero, let error be yes. Otherwise, let density
				// be the result.
				if (floatVal < 0) {
					pError = true;
				} else {
					density = floatVal;
				}
			}
			// If the descriptor consists of a valid non-negative integer followed by
			// a U+0068 LATIN SMALL LETTER H character
			else if (
				NON_NEGATIVE_INTEGER_REGEXP.test(value) &&
				lastChar === SMALL_LETTER_H
			) {
				// If height and density are not both absent, then let error be yes.
				if (height || density) {
					pError = true;
				}

				const intVal = Number.parseInt(value, 10);

				// Apply the rules for parsing non-negative integers to the descriptor.
				// If the result is zero, let error be yes. Otherwise, let future-compat-h
				// be the result.
				if (intVal === 0) {
					pError = true;
				} else {
					height = intVal;
				}

				// Anything else, Let error be yes.
			} else {
				pError = true;
			}
		}

		// 15. If error is still no, then append a new image source to candidates whose
		// URL is url, associated with a width width if not absent and a pixel
		// density density if not absent. Otherwise, there is a parse error.
		if (!pError) {
			candidates.push([
				/** @type {string} */ (url),
				start,
				start + /** @type {string} */ (url).length
			]);
		} else {
			throw new Error(
				`Invalid srcset descriptor found in '${input}' at '${desc}'`
			);
		}
	}

	/**
	 * @returns {void}
	 */
	function tokenize() {
		// 8.1. Descriptor tokenizer: Skip whitespace
		collectCharacters(LEADING_SPACES_REGEXP);

		// 8.2. Let current descriptor be the empty string.
		currentDescriptor = "";

		// 8.3. Let state be in descriptor.
		state = "in descriptor";

		while (true) {
			// 8.4. Let charCode be the character at position.
			charCode = input.charCodeAt(position);

			//  Do the following depending on the value of state.
			//  For the purpose of this step, "EOF" is a special character representing
			//  that position is past the end of input.

			// In descriptor
			if (state === "in descriptor") {
				// Do the following, depending on the value of charCode:

				// Space character
				// If current descriptor is not empty, append current descriptor to
				// descriptors and let current descriptor be the empty string.
				// Set state to after descriptor.
				if (isASCIIWhitespace(charCode)) {
					if (currentDescriptor) {
						descriptors.push(currentDescriptor);
						currentDescriptor = "";
						state = "after descriptor";
					}
				}
				// U+002C COMMA (,)
				// Advance position to the next character in input. If current descriptor
				// is not empty, append current descriptor to descriptors. Jump to the step
				// labeled descriptor parser.
				else if (charCode === COMMA) {
					position += 1;

					if (currentDescriptor) {
						descriptors.push(currentDescriptor);
					}

					parseDescriptors();

					return;
				}
				// U+0028 LEFT PARENTHESIS (()
				// Append charCode to current descriptor. Set state to in parens.
				else if (charCode === LEFT_PARENTHESIS) {
					currentDescriptor += input.charAt(position);
					state = "in parens";
				}
				// EOF
				// If current descriptor is not empty, append current descriptor to
				// descriptors. Jump to the step labeled descriptor parser.
				else if (Number.isNaN(charCode)) {
					if (currentDescriptor) {
						descriptors.push(currentDescriptor);
					}

					parseDescriptors();

					return;

					// Anything else
					// Append charCode to current descriptor.
				} else {
					currentDescriptor += input.charAt(position);
				}
			}
			// In parens
			else if (state === "in parens") {
				// U+0029 RIGHT PARENTHESIS ())
				// Append charCode to current descriptor. Set state to in descriptor.
				if (charCode === RIGHT_PARENTHESIS) {
					currentDescriptor += input.charAt(position);
					state = "in descriptor";
				}
				// EOF
				// Append current descriptor to descriptors. Jump to the step labeled
				// descriptor parser.
				else if (Number.isNaN(charCode)) {
					descriptors.push(currentDescriptor);
					parseDescriptors();
					return;
				}
				// Anything else
				// Append charCode to current descriptor.
				else {
					currentDescriptor += input.charAt(position);
				}
			}
			// After descriptor
			else if (state === "after descriptor") {
				// Do the following, depending on the value of charCode:
				if (isASCIIWhitespace(charCode)) {
					// Space character: Stay in this state.
				}
				// EOF: Jump to the step labeled descriptor parser.
				else if (Number.isNaN(charCode)) {
					parseDescriptors();
					return;
				}
				// Anything else
				// Set state to in descriptor. Set position to the previous character in input.
				else {
					state = "in descriptor";
					position -= 1;
				}
			}

			// Advance position to the next character in input.
			position += 1;
		}
	}

	// 3. Let candidates be an initially empty source set.
	// const candidates = []; // Moved to top

	// 4. Splitting loop: Collect a sequence of characters that are space
	//    characters or U+002C COMMA characters. If any U+002C COMMA characters
	//    were collected, that is a parse error.

	while (true) {
		collectCharacters(LEADING_COMMAS_OR_SPACES_REGEXP);

		// 5. If position is past the end of input, return candidates and abort these steps.
		if (position >= inputLength) {
			if (candidates.length === 0) {
				throw new Error("Must contain one or more image candidate strings");
			}

			// (we're done, this is the sole return path)
			return candidates;
		}

		// 6. Collect a sequence of characters that are not space characters,
		//    and let that be url.
		start = position;
		url = collectCharacters(LEADING_NOT_SPACES);

		// 7. Let descriptors be a new empty list.
		descriptors = [];

		// 8. If url ends with a U+002C COMMA character (,), follow these sub steps:
		//		(1). Remove all trailing U+002C COMMA characters from url. If this removed
		//         more than one character, that is a parse error.
		if (url && url.charCodeAt(url.length - 1) === COMMA) {
			url = url.replace(TRAILING_COMMAS_REGEXP, "");

			// (Jump ahead to step 9 to skip tokenization and just push the candidate).
			parseDescriptors();
		}
		//	Otherwise, follow these sub steps:
		else {
			tokenize();
		}

		// 16. Return to the step labeled splitting loop.
	}
};

/**
 * @param {Map<string, string>} attributes attributes
 * @param {string} name name
 * @returns {string | undefined} attribute value
 */
const getAttributeValue = (attributes, name) => attributes.get(name);

/** @type {Map<string, Set<string>>} */
const META = new Map([
	[
		"name",
		new Set([
			// msapplication-TileImage
			"msapplication-tileimage",
			"msapplication-square70x70logo",
			"msapplication-square150x150logo",
			"msapplication-wide310x150logo",
			"msapplication-square310x310logo",
			"msapplication-config",
			// TODO Do we need to parser it?
			// "msapplication-task",
			"twitter:image"
		])
	],
	[
		"property",
		new Set([
			"og:image",
			"og:image:url",
			"og:image:secure_url",
			"og:audio",
			"og:audio:secure_url",
			"og:video",
			"og:video:secure_url",
			"vk:image"
		])
	],
	[
		"itemprop",
		new Set([
			"image",
			"logo",
			"screenshot",
			"thumbnailurl",
			"contenturl",
			"downloadurl",
			"duringmedia",
			"embedurl",
			"installurl",
			"layoutimage"
		])
	]
]);

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when need to parse, otherwise false
 */
const filterLinkItemprop = (attributes) => {
	const value = getAttributeValue(attributes, "itemprop");
	if (!value) return false;
	const allowedAttributes = META.get("itemprop");
	if (!allowedAttributes) return false;

	return allowedAttributes.has(value.trim().toLowerCase());
};

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when need to parse, otherwise false
 */
const filterLinkHref = (attributes) => {
	const rel = getAttributeValue(attributes, "rel");
	if (!rel) return false;
	const usedRels = rel.trim().toLowerCase().split(" ").filter(Boolean);
	const allowedRels = [
		"stylesheet",
		"icon",
		"mask-icon",
		"apple-touch-icon",
		"apple-touch-icon-precomposed",
		"apple-touch-startup-image",
		"manifest",
		"prefetch",
		"preload",
		"modulepreload"
	];

	return allowedRels.some((value) => usedRels.includes(value));
};

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when need to parse, otherwise false
 */
const filterLinkUnion = (attributes) =>
	filterLinkHref(attributes) || filterLinkItemprop(attributes);

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when need to parse, otherwise false
 */
const filterMetaContent = (attributes) => {
	for (const item of META) {
		const [key, allowedNames] = item;
		const name = getAttributeValue(attributes, key);
		if (!name) continue;

		return allowedNames.has(name.trim().toLowerCase());
	}

	return false;
};

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when the script element opts into ES module semantics
 */
const isModuleScript = (attributes) => {
	const type = getAttributeValue(attributes, "type");
	if (!type) return false;
	return type.trim().toLowerCase() === "module";
};

// HTML `<script>` `type` values that the browser treats as executable
// JavaScript. Anything outside this set (e.g. `application/ld+json`,
// `importmap`, `application/wasm`) is a data block — webpack must not
// try to bundle it as a JS entry; it should pass through as an asset URL.
const JS_SCRIPT_TYPES = new Set([
	"",
	"module",
	"text/javascript",
	"application/javascript",
	"text/ecmascript",
	"application/ecmascript"
]);

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when the script element's `type` is executable JS
 */
const isExecutableJsScript = (attributes) => {
	const type = getAttributeValue(attributes, "type");
	if (type === undefined) return true;
	return JS_SCRIPT_TYPES.has(type.trim().toLowerCase());
};

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when the link points at an ES module that should be bundled as an entry chunk
 */
const isLinkModulePreload = (attributes) => {
	const rel = getAttributeValue(attributes, "rel");
	if (!rel) return false;
	return rel.trim().toLowerCase().split(/\s+/).includes("modulepreload");
};

/**
 * @param {Map<string, string>} attributes attributes
 * @returns {boolean} true when the link is a `<link rel="stylesheet">` that should be bundled as a CSS entry chunk
 */
const isLinkStylesheet = (attributes) => {
	const rel = getAttributeValue(attributes, "rel");
	if (!rel) return false;
	return rel.trim().toLowerCase().split(/\s+/).includes("stylesheet");
};

/** @type {Map<string, Map<string, { parse: (input: string) => ParsedSource[] | undefined, filter?: (attributes: Map<string, string>) => boolean, entry?: boolean | ((attributes: Map<string, string>) => boolean), entryCategory?: string }>>} */
const DEFAULT_SOURCES = new Map([
	[
		"audio",
		new Map([
			[
				"src",
				{
					parse: parseSrc
				}
			]
		])
	],
	[
		"embed",
		new Map([
			[
				"src",
				{
					parse: parseSrc
				}
			]
		])
	],
	[
		"img",
		new Map([
			[
				"src",
				{
					parse: parseSrc
				}
			],
			[
				"srcset",
				{
					parse: parseSrcset
				}
			]
		])
	],
	[
		"input",
		new Map([
			[
				"src",
				{
					parse: parseSrc
				}
			]
		])
	],
	[
		"link",
		new Map([
			[
				"href",
				{
					parse: parseSrc,
					filter: filterLinkUnion,
					entry: isLinkModulePreload,
					entryCategory: "esm"
				}
			],
			[
				"imagesrcset",
				{
					parse: parseSrcset,
					filter: filterLinkHref
				}
			]
		])
	],
	[
		"meta",
		new Map([
			[
				"content",
				{
					parse: parseSrc,
					filter: filterMetaContent
				}
			]
		])
	],
	[
		"object",
		new Map([
			[
				"data",
				{
					parse: parseSrc
				}
			]
		])
	],
	[
		"script",
		new Map([
			[
				"src",
				{
					parse: parseSrc,
					// Only executable-JS scripts become entries. Non-JS
					// `<script>` types (e.g. `application/ld+json`,
					// `importmap`) fall through to HtmlSourceDependency so
					// the browser keeps seeing them as data blocks, with
					// the asset URL rewritten like any other resource.
					entry: isExecutableJsScript
				}
			]
		])
	],
	[
		"source",
		new Map([
			[
				"src",
				{
					parse: parseSrc
				}
			],
			[
				"srcset",
				{
					parse: parseSrcset
				}
			]
		])
	],
	[
		"track",
		new Map([
			[
				"src",
				{
					parse: parseSrc
				}
			]
		])
	],
	[
		"video",
		new Map([
			[
				"poster",
				{
					parse: parseSrc
				}
			],
			[
				"src",
				{
					parse: parseSrc
				}
			]
		])
	],
	// SVG
	[
		"image",
		new Map([
			[
				"xlink:href",
				{
					parse: parseSrc
				}
			],
			[
				"href",
				{
					parse: parseSrc
				}
			]
		])
	],
	[
		"use",
		new Map([
			[
				"xlink:href",
				{
					parse: parseSrc
				}
			],
			[
				"href",
				{
					parse: parseSrc
				}
			]
		])
	]
]);

class HtmlParser extends Parser {
	/**
	 * Creates an instance of HtmlParser.
	 * @param {(string | typeof import("../util/Hash"))=} hashFunction algorithm or constructor used by `output.hashFunction`; falls back to the default when omitted
	 * @param {string=} context compilation context used to contextify the HTML module's identifier when seeding the entry-name hash
	 * @param {boolean=} outputModule whether `output.module` is enabled; when true, classic `<script src>` tags get `type="module"` auto-inserted so the rewritten src can load the emitted ES module chunk
	 * @param {boolean=} css whether `experiments.css` is enabled; when true, inline `<style>` bodies are routed through the CSS pipeline as `data:text/css` modules
	 */
	constructor(hashFunction, context, outputModule, css) {
		super();
		this.magicCommentContext = createMagicCommentContext();
		this.hashFunction = hashFunction;
		this.context = context;
		this.outputModule = outputModule;
		this.css = css;
	}

	/**
	 * Parses the provided source and updates the parser state.
	 * @param {string | Buffer | PreparsedAst} source the source to parse
	 * @param {ParserState} state the parser state
	 * @returns {ParserState} the parser state
	 */
	parse(source, state) {
		if (Buffer.isBuffer(source)) {
			source = source.toString("utf8");
		} else if (typeof source === "object") {
			throw new Error("webpackAst is unexpected for the HtmlParser");
		}
		if (source[0] === "\uFEFF") {
			source = source.slice(1);
		}

		const locConverter = new LocConverter(source);

		const module = state.module;

		// Stable, per-HTML-module prefix used when generating entry names for
		// script src / modulepreload references so they don't collide across
		// HTML modules in the same compilation. We hash the module's resource
		// path (a plain absolute path) — going through `contextify` against
		// the compilation root keeps the hash machine-stable for the same
		// project layout. Note: `module.identifier()` returns `html|<path>`
		// for HTML modules, which doesn't start with `/`, so contextify would
		// leave it absolute. `module.resource` is the bare path.
		/** @type {string} */
		const resource =
			/** @type {EXPECTED_ANY} */ (module).resource || module.identifier();
		const moduleHash = createHash(this.hashFunction || "md4")
			.update(this.context ? contextify(this.context, resource) : resource)
			.digest("hex")
			.slice(0, 8);

		/** @typedef {{ nameStart: number, nameEnd: number, valueStart: number, valueEnd: number }} AttrToken */

		/** @type {AttrToken[]} */
		const pendingAttributes = [];

		/**
		 * Reconciles the rewritten `<script>` tag's `type` attribute with the
		 * emitted chunk's actual format. Used by both the `<script src>` and
		 * inline `<script>` paths so the two stay in sync.
		 * @param {AttrToken | undefined} typeAttr existing `type` attribute, if any
		 * @param {number} nameEnd position right after `<script` (for inserts)
		 * @param {"classic" | "esm-script"} kind chunk kind decided by the parser
		 * @param {string} input full source string
		 * @returns {void}
		 */
		const reconcileScriptTypeAttr = (typeAttr, nameEnd, kind, input) => {
			if (this.outputModule && kind === "classic") {
				// Chunk is an ES module; upgrade the tag.
				if (typeAttr && typeAttr.valueStart !== -1) {
					module.addPresentationalDependency(
						new ConstDependency("module", [
							typeAttr.valueStart,
							typeAttr.valueEnd
						])
					);
				} else {
					module.addPresentationalDependency(
						new ConstDependency(' type="module"', nameEnd)
					);
				}
			} else if (!this.outputModule && kind === "esm-script" && typeAttr) {
				// Chunk is a classic IIFE; drop `type="module"` so the
				// browser doesn't load it under module semantics.
				let attrEnd;
				if (typeAttr.valueStart === -1) {
					attrEnd = typeAttr.nameEnd;
				} else if (
					input[typeAttr.valueEnd] === '"' ||
					input[typeAttr.valueEnd] === "'"
				) {
					attrEnd = typeAttr.valueEnd + 1;
				} else {
					attrEnd = typeAttr.valueEnd;
				}
				// Consume one leading whitespace char so we don't leave a
				// double space between `<script` and the next attribute.
				let attrStart = typeAttr.nameStart;
				if (
					attrStart > 0 &&
					isASCIIWhitespace(input.charCodeAt(attrStart - 1))
				) {
					attrStart -= 1;
				}
				module.addPresentationalDependency(
					new ConstDependency("", [attrStart, attrEnd])
				);
			}
		};

		// Inline `<script>` body extraction is deferred to the matching
		// `closeTag` event so the walker's script-data state machine
		// (including its escaped/double-escaped sub-states) decides where
		// the body ends. This is the spec-compliant way to find the close
		// — a plain `</script>` regex would split too early inside
		// `<!--<script>…</script>-->` patterns.
		/** @type {null | { contentStart: number, attrs: Map<string, string>, typeAttr: AttrToken | undefined, nameEnd: number }} */
		let pendingInlineScript = null;

		// Script src / modulepreload references are collected per-category
		// during the walk; HtmlModulesPlugin later turns them into real
		// entries. Classic <script src> and <script type="module" src> are
		// chained via a leader-only dependOn so they share a runtime.
		// `<link rel="modulepreload">` entries are kept independent — they
		// must preload without running, so they can never become a runtime
		// leader that other entries would import.
		/**
		 * @typedef {object} EntryScriptInfo
		 * @property {string} request
		 * @property {string} entryName
		 * @property {"classic" | "esm-script" | "modulepreload" | "stylesheet"} kind
		 */
		/** @type {EntryScriptInfo[]} */
		const classicEntries = [];
		/** @type {EntryScriptInfo[]} */
		const esmScriptEntries = [];
		/** @type {EntryScriptInfo[]} */
		const modulePreloadEntries = [];
		/** @type {EntryScriptInfo[]} */
		const stylesheetEntries = [];

		let nextEntryIndex = 0;

		/**
		 * Tracks the `webpackIgnore` value from the most recent comment that
		 * appears before the next tag. Reset whenever a tag is emitted or a
		 * comment without a `webpackIgnore` value is encountered.
		 * @type {boolean | undefined}
		 */
		let pendingWebpackIgnore;

		const magicCommentContext = this.magicCommentContext;

		// TODO implement full HTML parser (WASM)
		walkHtmlTokens(source, 0, {
			comment: (input, start, end) => {
				// Only proper `<!-- ... -->` comments carry magic comments.
				// `walkHtmlTokens` also dispatches this callback for bogus
				// comments such as `<!DOCTYPE …>` and `<?…>`, which must not
				// be parsed as magic comments.
				if (
					end - start < 7 ||
					input.charCodeAt(start) !== 0x3c /* < */ ||
					input.charCodeAt(start + 1) !== 0x21 /* ! */ ||
					input.charCodeAt(start + 2) !== 0x2d /* - */ ||
					input.charCodeAt(start + 3) !== 0x2d /* - */ ||
					input.charCodeAt(end - 1) !== 0x3e /* > */ ||
					input.charCodeAt(end - 2) !== 0x2d /* - */ ||
					input.charCodeAt(end - 3) !== 0x2d /* - */
				) {
					pendingWebpackIgnore = undefined;
					return end;
				}
				const contentStart = start + 4;
				const contentEnd = end - 3;
				const value = input.slice(contentStart, contentEnd);
				if (!webpackCommentRegExp.test(value)) {
					pendingWebpackIgnore = undefined;
					return end;
				}
				/** @type {Record<string, EXPECTED_ANY>} */
				let options;
				try {
					options = vm.runInContext(
						`(function(){return {${value}};})()`,
						magicCommentContext
					);
				} catch (err) {
					const { line: sl, column: sc } = locConverter.get(start);
					const { line: el, column: ec } = locConverter.get(end);
					module.addWarning(
						new CommentCompilationWarning(
							`Compilation error while processing magic comment(-s): /*${value}*/: ${
								/** @type {Error} */ (err).message
							}`,
							{
								start: { line: sl, column: sc },
								end: { line: el, column: ec }
							}
						)
					);
					pendingWebpackIgnore = undefined;
					return end;
				}
				if (options.webpackIgnore === undefined) {
					pendingWebpackIgnore = undefined;
					return end;
				}
				if (typeof options.webpackIgnore !== "boolean") {
					const { line: sl, column: sc } = locConverter.get(start);
					const { line: el, column: ec } = locConverter.get(end);
					module.addWarning(
						new UnsupportedFeatureWarning(
							`\`webpackIgnore\` expected a boolean, but received: ${options.webpackIgnore}.`,
							{
								start: { line: sl, column: sc },
								end: { line: el, column: ec }
							}
						)
					);
					pendingWebpackIgnore = undefined;
					return end;
				}
				pendingWebpackIgnore = options.webpackIgnore;
				return end;
			},
			attribute: (
				input,
				nameStart,
				nameEnd,
				valueStart,
				valueEnd,
				quoteType
			) => {
				pendingAttributes.push({ nameStart, nameEnd, valueStart, valueEnd });
				if (valueStart === -1) return nameEnd;
				return quoteType !== walkHtmlTokens.QUOTE_NONE
					? valueEnd + 1
					: valueEnd;
			},
			closeTag: (input, start, end, nameStart, nameEnd) => {
				pendingWebpackIgnore = undefined;
				if (pendingInlineScript) {
					const elName = input.slice(nameStart, nameEnd).toLowerCase();
					if (elName === "script") {
						const ps = pendingInlineScript;
						pendingInlineScript = null;
						const contentStart = ps.contentStart;
						const contentEnd = start; // start of `</script>`
						const jsContent = input.slice(contentStart, contentEnd);
						if (jsContent.trim() === "") return end;

						// Base64-encode the JS body so the data URI round-trips
						// arbitrary JavaScript text, including non-ASCII source
						// (`decodeDataURI` decodes non-base64 bodies as ASCII,
						// which would corrupt Unicode string literals or
						// identifiers).
						const request = `data:text/javascript;base64,${Buffer.from(
							jsContent,
							"utf8"
						).toString("base64")}`;

						const useEsmEntry = isModuleScript(ps.attrs);
						const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
						/** @type {"classic" | "esm-script"} */
						const kind = useEsmEntry ? "esm-script" : "classic";
						const { line: sl, column: sc } = locConverter.get(contentStart);
						const { line: el, column: ec } = locConverter.get(contentEnd);
						const dep = new HtmlInlineScriptDependency(
							request,
							ps.nameEnd,
							[contentStart, contentEnd],
							entryName,
							useEsmEntry ? "esm" : "commonjs"
						);
						dep.setLoc(sl, sc, el, ec);
						module.addPresentationalDependency(dep);
						reconcileScriptTypeAttr(ps.typeAttr, ps.nameEnd, kind, input);
						const collection =
							kind === "classic" ? classicEntries : esmScriptEntries;
						collection.push({ request, entryName, kind });
					}
				}
				return end;
			},
			openTag: (input, start, end, nameStart, nameEnd) => {
				const ignore = pendingWebpackIgnore === true;
				pendingWebpackIgnore = undefined;
				if (ignore) {
					// For `<script>` and `<style>` we don't emit a dependency,
					// but we must NOT advance past the close tag either: the
					// walker is already in script-data/rawtext state for
					// those tags and will consume the body and emit the
					// matching `closeTag` itself. Returning a position past
					// `</script>`/`</style>` would leave the walker stuck in
					// rawtext mode, swallowing later markup.
					pendingAttributes.length = 0;
					return end;
				}
				const elementName = input.slice(nameStart, nameEnd).toLowerCase();

				// `<style>` is rawtext: capture the inline CSS and hand it to
				// the CSS pipeline as a virtual `data:text/css` module with
				// `exportType: "text"`. We use the regex only to discover the
				// `</style>` position so we can slice the body; the walker is
				// already in rawtext state for `<style>` and will emit the
				// matching `closeTag` event itself, so we must return `end`
				// (the position right after the opening tag's `>`) rather
				// than advancing past `</style>`. Only `<style>` tags whose
				// `type` attribute is absent, empty, or `text/css` are
				// processed; other types are left untouched.
				if (elementName === "style") {
					/** @type {string | undefined} */
					let typeValue;
					for (const attr of pendingAttributes) {
						const attrName = input
							.slice(attr.nameStart, attr.nameEnd)
							.toLowerCase();
						if (attrName === "type") {
							typeValue =
								attr.valueStart !== -1
									? input.slice(attr.valueStart, attr.valueEnd)
									: "";
							break;
						}
					}
					pendingAttributes.length = 0;

					STYLE_END_REGEXP.lastIndex = end;
					const closeMatch = STYLE_END_REGEXP.exec(input);
					if (!closeMatch) return end;
					const contentStart = end;
					const contentEnd = closeMatch.index;

					const trimmedType =
						typeValue !== undefined ? typeValue.trim().toLowerCase() : "";
					if (
						typeValue !== undefined &&
						trimmedType !== "" &&
						trimmedType !== "text/css"
					) {
						return end;
					}

					// Inline-style processing requires the CSS pipeline; when
					// `experiments.css` is off, leave the body alone — the
					// walker is in rawtext mode for `<style>` and will skip
					// over the body to the matching `</style>` itself.
					if (!this.css) {
						return end;
					}

					const cssContent = input.slice(contentStart, contentEnd);
					if (cssContent.trim() === "") {
						return end;
					}

					// URL-encode the CSS body so the data URI parser's `(.*)$`
					// body group matches even when the source has newlines or
					// other characters that would otherwise break the regex.
					const request = `data:text/css,${encodeURIComponent(cssContent)}`;

					const { line: sl, column: sc } = locConverter.get(contentStart);
					const { line: el, column: ec } = locConverter.get(contentEnd);
					const dep = new HtmlInlineStyleDependency(request, [
						contentStart,
						contentEnd
					]);
					dep.setLoc(sl, sc, el, ec);
					module.addDependency(dep);
					module.addCodeGenerationDependency(dep);
					return end;
				}

				const sources = DEFAULT_SOURCES.get(elementName);

				if (!sources) {
					pendingAttributes.length = 0;
					return end;
				}

				/** @type {Map<string, string> | undefined} */
				let attributesMap;
				const getAttributesMap = () => {
					if (attributesMap) return attributesMap;
					attributesMap = new Map();
					for (const attr of pendingAttributes) {
						const name = input
							.slice(attr.nameStart, attr.nameEnd)
							.toLowerCase();
						const value =
							attr.valueStart !== -1
								? input.slice(attr.valueStart, attr.valueEnd)
								: "";
						attributesMap.set(name, value);
					}
					return attributesMap;
				};

				for (const attr of pendingAttributes) {
					const attributeName = input
						.slice(attr.nameStart, attr.nameEnd)
						.toLowerCase();
					const sourceItem = sources.get(attributeName);

					if (!sourceItem) continue;

					// TODO(html-entities): We should ideally decode entities here using
					// `walkHtmlTokens.decodeHtmlEntities(input.slice(...))` so that URLs
					// like `image.png?a=1&amp;b=2` are correctly resolved as `&`.
					// However, doing so currently breaks `srcset` parsing tests (e.g. `errors.js`)
					// which explicitly expect whitespace entities like `&#x9;` to NOT be decoded
					// before the srcset parser runs. A follow-up PR should implement selective
					// decoding for specific URL attributes.
					const attributeValue =
						attr.valueStart !== -1
							? input.slice(attr.valueStart, attr.valueEnd)
							: "";

					if (!attributeValue) continue;

					if (
						typeof sourceItem.filter === "function" &&
						!sourceItem.filter(getAttributesMap())
					) {
						continue;
					}

					/** @type {ParsedSource[] | undefined} */
					let parsedAttributeValue;

					try {
						parsedAttributeValue = sourceItem.parse(attributeValue);
					} catch (err) {
						const { line: sl, column: sc } = locConverter.get(attr.valueStart);
						const { line: el, column: ec } = locConverter.get(attr.valueEnd);

						module.addError(
							new ModuleDependencyError(
								module,
								new WebpackError(
									`Bad value for attribute "${attributeName}" on element "${elementName}": ${
										/** @type {Error} */ (err).message
									}`
								),
								{
									start: { line: sl, column: sc },
									end: { line: el, column: ec }
								}
							)
						);
					}

					if (!parsedAttributeValue) continue;

					// `<link rel="stylesheet">` is upgraded to an entry only when
					// `experiments.css` is on — that's the mode where webpack can
					// bundle the CSS into its own chunk. Without it, the
					// stylesheet href stays a plain asset URL. Scope this to the
					// `href` attribute only: `<link>` also exposes
					// `imagesrcset` URLs which must continue to flow through
					// the regular asset rewriting path even on a stylesheet
					// link.
					const isStylesheetEntry =
						this.css &&
						elementName === "link" &&
						attributeName === "href" &&
						isLinkStylesheet(getAttributesMap());
					const isEntry =
						isStylesheetEntry ||
						sourceItem.entry === true ||
						(typeof sourceItem.entry === "function" &&
							sourceItem.entry(getAttributesMap()));

					// `<script type="module" src>` and `<link rel="modulepreload">`
					// reference ES modules; everything else under `<script src>` is a
					// classic script. The category drives ESM vs CommonJS resolution
					// of the entry — the chunk format is controlled by the user via
					// `output.module` / `experiments.outputModule`.
					const useEsmEntry =
						(elementName === "script" && isModuleScript(getAttributesMap())) ||
						sourceItem.entryCategory === "esm";

					for (const parsedSource of parsedAttributeValue) {
						const [value, innerStart, innerEnd] = parsedSource;
						if (value.startsWith("#")) continue;
						const sourceStart = attr.valueStart + innerStart;
						const sourceEnd = attr.valueStart + innerEnd;
						const { line: sl, column: sc } = locConverter.get(sourceStart);
						const { line: el, column: ec } = locConverter.get(sourceEnd);
						if (isEntry) {
							const entryName = `__html_${moduleHash}_${nextEntryIndex++}`;
							const isStylesheetLink =
								elementName === "link" && isLinkStylesheet(getAttributesMap());
							/** @type {"classic" | "esm-script" | "modulepreload" | "stylesheet"} */
							const kind =
								elementName === "link"
									? isStylesheetLink
										? "stylesheet"
										: "modulepreload"
									: useEsmEntry
										? "esm-script"
										: "classic";
							// With `output.module` enabled, a classic `<script src>` is
							// upgraded in place to `<script type="module" src>` (see the
							// ConstDependency insertion below). Account for that in the
							// dependency's `elementKind` so sibling tags emitted by the
							// template for additional entry chunks (runtime / split chunks)
							// also use `type="module"`.
							const willBeModuleScript =
								kind === "esm-script" ||
								(this.outputModule &&
									kind === "classic" &&
									elementName === "script");
							/** @type {"script-classic" | "script-module" | "modulepreload" | "stylesheet"} */
							const elementKind =
								kind === "modulepreload"
									? "modulepreload"
									: kind === "stylesheet"
										? "stylesheet"
										: willBeModuleScript
											? "script-module"
											: "script-classic";
							const dep = new HtmlScriptSrcDependency(
								value,
								[sourceStart, sourceEnd],
								entryName,
								// `<link rel="stylesheet">` is bundled as a CSS entry —
								// using a non-"url" category so the default `.css` rule
								// (which gives the resolved module the CSS module type)
								// wins over the `dependency: "url"` → asset rule.
								kind === "stylesheet"
									? "css-import"
									: useEsmEntry
										? "esm"
										: sourceItem.entryCategory,
								elementKind,
								start,
								end
							);
							dep.setLoc(sl, sc, el, ec);
							module.addPresentationalDependency(dep);
							// Reconcile the rewritten `<script>` tag's `type`
							// attribute with the chunk's actual format. See
							// `reconcileScriptTypeAttr` for the rules.
							if (
								elementName === "script" &&
								(kind === "classic" || kind === "esm-script")
							) {
								/** @type {AttrToken | undefined} */
								let typeAttr;
								for (const a of pendingAttributes) {
									if (
										input.slice(a.nameStart, a.nameEnd).toLowerCase() === "type"
									) {
										typeAttr = a;
										break;
									}
								}
								reconcileScriptTypeAttr(typeAttr, nameEnd, kind, input);
							}
							const collection =
								kind === "classic"
									? classicEntries
									: kind === "esm-script"
										? esmScriptEntries
										: kind === "stylesheet"
											? stylesheetEntries
											: modulePreloadEntries;
							collection.push({ request: value, entryName, kind });
						} else {
							const dep = new HtmlSourceDependency(value, [
								sourceStart,
								sourceEnd
							]);
							dep.setLoc(sl, sc, el, ec);
							module.addDependency(dep);
							module.addCodeGenerationDependency(dep);
						}
					}
				}

				// `<script>` is rawtext (the "script data state" in the HTML
				// tokenizer): its body must never be reparsed as HTML,
				// regardless of whether the tag has a `src` attribute. The
				// walker is already in script-data state for `<script>` and
				// will emit the matching `closeTag` event itself, so we
				// return `end` and defer body extraction to the closeTag
				// callback (which gets the spec-correct `</script>` position
				// even in escaped/double-escaped script-data sub-states).
				// When the tag has no `src` and its body is non-empty, the
				// closeTag handler bundles the inline JS as its own entry —
				// the same pipeline that processes `<script src>` — by
				// issuing a `data:text/javascript;base64,...` virtual request
				// and adding a dependency that rewrites the tag to
				// `<script src="…">` at render time. Only `<script>` tags
				// whose `type` attribute is absent, empty, or a recognized
				// JS mimetype are processed as JS; other types (e.g.
				// `application/ld+json`, `importmap`) are left untouched.
				if (elementName === "script") {
					const attrs = getAttributesMap();
					// Use attribute presence, not value: a `<script src>` with
					// an empty or valueless `src` still ignores its inline
					// body in the browser, so we must not bundle the body.
					const hasSrc = attrs.has("src");

					/** @type {AttrToken | undefined} */
					let typeAttr;
					for (const a of pendingAttributes) {
						if (input.slice(a.nameStart, a.nameEnd).toLowerCase() === "type") {
							typeAttr = a;
							break;
						}
					}
					pendingAttributes.length = 0;

					if (hasSrc || !isExecutableJsScript(attrs)) {
						// `<script src>` body is ignored by the browser, and
						// non-JS `<script type>` (e.g. importmap, JSON-LD)
						// passes through unchanged. Either way the walker
						// consumes the body and emits the close tag itself.
						return end;
					}

					pendingInlineScript = {
						contentStart: end,
						attrs,
						typeAttr,
						nameEnd
					};
					return end;
				}

				pendingAttributes.length = 0;
				return end;
			}
		});

		const buildInfo = /** @type {BuildInfo} */ (module.buildInfo);
		buildInfo.strict = true;
		// Hand off the collected entries to HtmlModulesPlugin; it creates the
		// real compilation entries during the finishMake hook. The classic
		// and esm-script groups are chained via a leader-only dependOn so
		// they share a runtime; modulepreload entries are emitted as
		// independent entries since `<link rel=modulepreload>` must preload
		// without running.
		if (
			classicEntries.length > 0 ||
			esmScriptEntries.length > 0 ||
			modulePreloadEntries.length > 0 ||
			stylesheetEntries.length > 0
		) {
			/** @type {Record<string, EntryScriptInfo[]>} */
			(buildInfo.htmlEntryScripts) = {
				classic: classicEntries,
				"esm-script": esmScriptEntries,
				modulepreload: modulePreloadEntries,
				stylesheet: stylesheetEntries
			};
		}

		const buildMeta = /** @type {BuildMeta} */ (state.module.buildMeta);
		buildMeta.exportsType = "default";

		state.module.addDependency(new StaticExportsDependency(["default"], true));

		return state;
	}
}

module.exports = HtmlParser;
