/**
 * allowedCommand → tags / TinyMCE toolbar, plus HTML sanitizer.
 *
 * Strips scripts, media, and all attributes except the few needed for
 * allowed formatting (e.g. span style color when "color" is allowed).
 */

/** @type {string[]} */
export const ALL_RICH_COMMANDS = [
	'bold',
	'italic',
	'underline',
	'strikethrough',
	'color',
	'heading',
	'fontfamily',
	'fontsize',
	'fontweight',
	'bullist',
	'numlist',
	'blockquote',
	'alignleft',
	'aligncenter',
	'alignright',
	'link',
	'subscript',
	'superscript',
	'charmap',
	'hr',
	'removeformat',
	'undo',
	'redo',
];

/** Title / heading: inline formatting only. */
export const TITLE_ALLOWED_COMMANDS = [
	'heading',
	'fontweight',
	'italic',
	'underline',
	'strikethrough',
	'color',
	'fontfamily',
	'removeformat',
];

/** @type {Record<string, string[]>} */
const COMMAND_TAGS = {
	bold: [ 'strong', 'b' ],
	italic: [ 'em', 'i' ],
	underline: [ 'u' ],
	strikethrough: [ 's', 'strike', 'del' ],
	color: [ 'span' ],
	heading: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ],
	fontfamily: [ 'span' ],
	fontsize: [ 'span' ],
	fontweight: [ 'span', 'strong', 'b' ],
	lineheight: [ 'span' ],
	letterspacing: [ 'span' ],
	font: [ 'span' ],
	bullist: [ 'ul', 'li' ],
	numlist: [ 'ol', 'li' ],
	/** Keep <li> structure without exposing ul/ol toolbar buttons (checklists). */
	listitem: [ 'li' ],
	blockquote: [ 'blockquote' ],
	link: [ 'a' ],
	subscript: [ 'sub' ],
	superscript: [ 'sup' ],
	hr: [ 'hr' ],
	// Align does not add tags; text-align style allowed on block wrappers below.
	alignleft: [],
	aligncenter: [],
	alignright: [],
};

const VOID_TAGS = new Set( [ 'br', 'hr' ] );

const ALWAYS_DROP = new Set( [
	'script',
	'iframe',
	'object',
	'embed',
	'img',
	'video',
	'audio',
	'source',
	'picture',
	'svg',
	'canvas',
	'form',
	'input',
	'button',
	'textarea',
	'select',
	'option',
	'link',
	'meta',
	'base',
	'style',
	'noscript',
	'template',
	'math',
	'frame',
	'frameset',
	'applet',
	'param',
	'track',
	'map',
	'area',
	'portal',
	'marquee',
	'plaintext',
	'xmp',
	'listing',
	'keygen',
	'isindex',
	'dialog',
	'webview',
	'foreignobject',
] );

/**
 * @param {unknown} raw
 * @return {string[]|null} Normalized list, or null when unrestricted (full rich).
 */
export function normalizeAllowedCommands( raw ) {
	if ( raw == null ) {
		return null;
	}
	const list = Array.isArray( raw )
		? raw
		: typeof raw === 'string'
		? raw.split( /[\s,|]+/ )
		: [];
	const out = [];
	const seen = new Set();
	list.forEach( ( item ) => {
		const cmd = String( item || '' )
			.trim()
			.toLowerCase()
			.replace( /^mce_/, '' );
		if ( ! cmd || seen.has( cmd ) ) {
			return;
		}
		// Aliases
		const alias =
			cmd === 'strike'
				? 'strikethrough'
				: cmd === 'forecolor' || cmd === 'textcolor'
				? 'color'
				: cmd === 'font' || cmd === 'font-family' || cmd === 'fontFamily'
				? 'fontfamily'
				: cmd === 'font-size' || cmd === 'fontSize' || cmd === 'size'
				? 'fontsize'
				: cmd === 'font-weight' ||
				  cmd === 'fontWeight' ||
				  cmd === 'weight' ||
				  cmd === 'bold'
				? 'fontweight'
				: cmd === 'line-height' ||
				  cmd === 'lineHeight' ||
				  cmd === 'lineheight'
				? 'lineheight'
				: cmd === 'letter-spacing' ||
				  cmd === 'letterSpacing' ||
				  cmd === 'letterspacing' ||
				  cmd === 'tracking'
				? 'letterspacing'
				: cmd === 'h1' ||
				  cmd === 'h2' ||
				  cmd === 'h3' ||
				  cmd === 'h4' ||
				  cmd === 'h5' ||
				  cmd === 'h6' ||
				  cmd === 'headings' ||
				  cmd === 'formatblock'
				? 'heading'
				: cmd === 'ul'
				? 'bullist'
				: cmd === 'ol'
				? 'numlist'
				: cmd === 'list-item' || cmd === 'li'
				? 'listitem'
				: cmd;
		seen.add( alias );
		out.push( alias );
	} );
	return out;
}

/**
 * Resolve schema field → command list (null = full toolbar).
 *
 * @param {object} [field]
 * @return {string[]|null}
 */
export function allowedCommandsFromField( field ) {
	if ( ! field || typeof field !== 'object' ) {
		return null;
	}
	if ( isAdvanceEditorDisabled( field ) ) {
		// No formatting tags — plain text only.
		return [];
	}
	const raw =
		field.allowedCommand ??
		field.allowedCommands ??
		field.allowed_commands ??
		null;
	return normalizeAllowedCommands( raw );
}

/**
 * Schema flag: plain text only — no format toolbar; strip all HTML tags.
 *
 * @param {object} [field]
 * @param {boolean} [explicit]
 * @return {boolean}
 */
export function isAdvanceEditorDisabled( field, explicit ) {
	if ( typeof explicit === 'boolean' ) {
		return explicit;
	}
	if ( ! field || typeof field !== 'object' ) {
		return false;
	}
	const raw =
		field.disableAdvanceEditor ??
		field.disableAdvancedEditor ??
		field.disable_advance_editor ??
		false;
	return raw === true || raw === 1 || raw === '1' || raw === 'true';
}

/**
 * Strip every HTML tag — plain text only (for disableAdvanceEditor fields).
 *
 * @param {string} html
 * @return {string}
 */
export function stripToPlainText( html ) {
	const raw = String( html ?? '' );
	if ( ! raw ) {
		return '';
	}
	if ( typeof document === 'undefined' ) {
		return raw
			.replace( /<[^>]*>/g, ' ' )
			.replace( /&nbsp;/gi, ' ' )
			.replace( /&amp;/g, '&' )
			.replace( /&lt;/g, '<' )
			.replace( /&gt;/g, '>' )
			.replace( /&quot;/g, '"' )
			.replace( /\s+/g, ' ' )
			.trim();
	}
	const parser = new DOMParser();
	const doc = parser.parseFromString(
		`<div id="bm-plain-root">${ raw }</div>`,
		'text/html'
	);
	const root = doc.getElementById( 'bm-plain-root' );
	const text = ( root?.textContent || '' ).replace( /\u00a0/g, ' ' );
	return text.replace( /[\r\n\t]+/g, ' ' ).replace( / {2,}/g, ' ' ).trim();
}

/**
 * Tags permitted for the given commands (plus br always).
 *
 * @param {string[]|null} commands
 * @return {{ tags: Set<string>, allowColor: boolean, allowLink: boolean, allowAlign: boolean }}
 */
export function allowedTagsFromCommands( commands ) {
	const cmds = commands == null ? ALL_RICH_COMMANDS : commands;
	const tags = new Set( [ 'br' ] );
	let allowColor = false;
	let allowLink = false;
	let allowAlign = false;
	let allowFontFamily = false;
	let allowFontSize = false;
	let allowFontWeight = false;
	let allowLineHeight = false;
	let allowLetterSpacing = false;

	cmds.forEach( ( cmd ) => {
		( COMMAND_TAGS[ cmd ] || [] ).forEach( ( t ) => tags.add( t ) );
		if ( cmd === 'color' ) {
			allowColor = true;
		}
		if ( cmd === 'fontfamily' || cmd === 'font' ) {
			allowFontFamily = true;
			tags.add( 'span' );
		}
		if ( cmd === 'fontsize' ) {
			allowFontSize = true;
			tags.add( 'span' );
		}
		if ( cmd === 'fontweight' ) {
			allowFontWeight = true;
			tags.add( 'span' );
		}
		if ( cmd === 'lineheight' ) {
			allowLineHeight = true;
			tags.add( 'span' );
		}
		if ( cmd === 'letterspacing' ) {
			allowLetterSpacing = true;
			tags.add( 'span' );
		}
		if ( cmd === 'link' ) {
			allowLink = true;
		}
		if (
			cmd === 'alignleft' ||
			cmd === 'aligncenter' ||
			cmd === 'alignright'
		) {
			allowAlign = true;
		}
	} );

	// Do not allow <p>/<div> — browsers insert them on Enter/paste.
	// Sanitize unwraps those blocks to <br> so only span (+ allowed inline) and br remain.
	// Full rich editor also allows font wrappers by default.
	if ( commands == null ) {
		allowFontFamily = true;
		allowFontSize = true;
		allowFontWeight = true;
		allowLineHeight = true;
		allowLetterSpacing = true;
		tags.add( 'span' );
	}

	// Fonts panel extras: allow line-height / letter-spacing with any type control.
	if ( allowFontFamily || allowFontSize || allowFontWeight ) {
		allowLineHeight = true;
		allowLetterSpacing = true;
		tags.add( 'span' );
	}

	return {
		tags,
		allowColor,
		allowLink,
		allowAlign,
		allowFontFamily,
		allowFontSize,
		allowFontWeight,
		allowLineHeight,
		allowLetterSpacing,
	};
}

/**
 * TinyMCE toolbar1 string from allowed commands.
 *
 * @param {string[]|null} commands
 * @return {string}
 */
export function toolbarFromAllowedCommands( commands ) {
	const cmds = commands == null ? ALL_RICH_COMMANDS : commands;
	const set = new Set( cmds );
	const order = [
		'bold',
		'italic',
		'underline',
		'strikethrough',
		'forecolor',
		'formatselect',
		'fontfamily',
		'fontsize',
		'bullist',
		'numlist',
		'blockquote',
		'alignleft',
		'aligncenter',
		'alignright',
		'link',
		'unlink',
		'subscript',
		'superscript',
		'charmap',
		'hr',
		'removeformat',
		'undo',
		'redo',
	];

	const buttons = [];
	order.forEach( ( btn ) => {
		const cmd =
			btn === 'forecolor'
				? 'color'
				: btn === 'unlink'
				? 'link'
				: btn === 'formatselect'
				? 'heading'
				: btn;
		if ( ! set.has( cmd ) ) {
			return;
		}
		// Always pair undo/redo when any formatting is available.
		buttons.push(
			btn === 'forecolor'
				? 'forecolor'
				: btn === 'formatselect'
				? 'formatselect'
				: btn
		);
	} );

	// Ensure undo/redo / clear when we have a restricted but non-empty toolbar.
	if ( buttons.length ) {
		if ( ! buttons.includes( 'removeformat' ) ) {
			buttons.push( 'removeformat' );
		}
		if ( ! buttons.includes( 'undo' ) ) {
			buttons.push( 'undo', 'redo' );
		}
	}

	return buttons.join( ',' );
}

/**
 * TinyMCE valid_elements for restricted editors.
 *
 * @param {string[]|null} commands
 * @return {string}
 */
export function tinymceValidElements( commands ) {
	const { tags, allowColor, allowLink, allowAlign, allowFontFamily, allowFontSize, allowFontWeight, allowLineHeight, allowLetterSpacing } =
		allowedTagsFromCommands( commands );
	const parts = [];
	tags.forEach( ( tag ) => {
		if ( tag === 'br' || tag === 'hr' ) {
			parts.push( tag );
			return;
		}
		if (
			tag === 'span' &&
			( allowColor ||
				allowAlign ||
				allowFontFamily ||
				allowFontSize ||
				allowFontWeight ||
				allowLineHeight ||
				allowLetterSpacing )
		) {
			parts.push( 'span[style]' );
			return;
		}
		if ( tag === 'a' && allowLink ) {
			parts.push( 'a[href|target|rel]' );
			return;
		}
		parts.push( tag );
	} );
	return parts.join( ',' );
}

/**
 * Extract a safe CSS color from an element's style / color attr.
 * Preserves alpha when present (rgba / #rrggbbaa).
 *
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeColor( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	const fromStyle = style.match( /(?:^|;)\s*color\s*:\s*([^;]+)/i );
	const candidates = [
		fromStyle ? fromStyle[ 1 ].trim() : '',
		el.getAttribute?.( 'color' ) || '',
		el.getAttribute?.( 'data-mce-style' )
			? String( el.getAttribute( 'data-mce-style' ) ).match(
					/(?:^|;)\s*color\s*:\s*([^;]+)/i
			  )?.[ 1 ]
			: '',
	].filter( Boolean );

	for ( const raw of candidates ) {
		const safe = sanitizeCssColor( raw );
		if ( safe ) {
			return safe;
		}
	}
	return null;
}

/**
 * Normalize a CSS color string to a safe hex / rgba value.
 *
 * @param {string} raw
 * @return {string|null}
 */
export function sanitizeCssColor( raw ) {
	const c = String( raw || '' )
		.trim()
		.replace( /!important\s*$/i, '' )
		.trim();
	if ( ! c ) {
		return null;
	}
	if ( c.toLowerCase() === 'transparent' ) {
		return 'transparent';
	}
	if ( /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test( c ) ) {
		if ( c.length === 4 ) {
			return `#${ c[ 1 ] }${ c[ 1 ] }${ c[ 2 ] }${ c[ 2 ] }${ c[ 3 ] }${ c[ 3 ] }`.toLowerCase();
		}
		if ( c.length === 9 ) {
			return c.toLowerCase();
		}
		return c.slice( 0, 7 ).toLowerCase();
	}
	const rgba = c.match(
		/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*([\d.]+)\s*)?\)$/i
	);
	if ( rgba ) {
		const r = Math.min( 255, Number( rgba[ 1 ] ) );
		const g = Math.min( 255, Number( rgba[ 2 ] ) );
		const b = Math.min( 255, Number( rgba[ 3 ] ) );
		const a =
			rgba[ 4 ] !== undefined ? Math.min( 1, Math.max( 0, Number( rgba[ 4 ] ) ) ) : 1;
		if ( a < 1 ) {
			const alpha = Number.isFinite( a ) ? Number( a.toFixed( 3 ) ) : 1;
			return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
		}
		return (
			'#' +
			[ r, g, b ]
				.map( ( n ) => n.toString( 16 ).padStart( 2, '0' ) )
				.join( '' )
		);
	}
	// Modern space-separated: rgb(255 0 0) / rgba(255 0 0 / 0.5)
	const rgbaSpace = c.match(
		/^rgba?\(\s*(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})(?:\s*\/\s*([\d.]+%?)\s*)?\)$/i
	);
	if ( rgbaSpace ) {
		const r = Math.min( 255, Number( rgbaSpace[ 1 ] ) );
		const g = Math.min( 255, Number( rgbaSpace[ 2 ] ) );
		const b = Math.min( 255, Number( rgbaSpace[ 3 ] ) );
		let a = 1;
		if ( rgbaSpace[ 4 ] !== undefined ) {
			const rawA = String( rgbaSpace[ 4 ] );
			a = rawA.endsWith( '%' )
				? Math.min( 1, Math.max( 0, Number( rawA.slice( 0, -1 ) ) / 100 ) )
				: Math.min( 1, Math.max( 0, Number( rawA ) ) );
		}
		if ( a < 1 ) {
			const alpha = Number.isFinite( a ) ? Number( a.toFixed( 3 ) ) : 1;
			return `rgba(${ r }, ${ g }, ${ b }, ${ alpha })`;
		}
		return (
			'#' +
			[ r, g, b ]
				.map( ( n ) => n.toString( 16 ).padStart( 2, '0' ) )
				.join( '' )
		);
	}
	return null;
}

/**
 * Extract safe font-family from style when fontfamily command is allowed.
 *
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeFontFamily( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	if ( /expression\s*\(|url\s*\(|-moz-binding|behavior\s*:|@import/i.test( style ) ) {
		return null;
	}
	const m = style.match( /(?:^|;)\s*font-family\s*:\s*([^;]+)/i );
	if ( ! m ) {
		return null;
	}
	const family = String( m[ 1 ] || '' )
		.trim()
		.replace( /["']/g, '' )
		.replace( /[<>;{}()]/g, '' )
		.slice( 0, 120 );
	if ( /url|expression|javascript/i.test( family ) ) {
		return null;
	}
	return family || null;
}

/**
 * Extract safe font-size from style when fontsize command is allowed.
 *
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeFontSize( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	const m = style.match( /(?:^|;)\s*font-size\s*:\s*([^;]+)/i );
	if ( ! m ) {
		return null;
	}
	const raw = String( m[ 1 ] || '' )
		.trim()
		.replace( /["']/g, '' )
		.toLowerCase();
	if ( ! /^[\d.]+\s*(px|rem|em|%)?$/.test( raw ) ) {
		return null;
	}
	const normalized = /[a-z%]$/i.test( raw.replace( /\s/g, '' ) )
		? raw.replace( /\s/g, '' )
		: `${ parseFloat( raw ) }px`;
	if ( ! Number.isFinite( parseFloat( normalized ) ) ) {
		return null;
	}
	return normalized.slice( 0, 24 );
}

/**
 * Extract safe font-weight (100–900 step 100, or named keywords).
 *
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeFontWeight( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	const m = style.match( /(?:^|;)\s*font-weight\s*:\s*([^;]+)/i );
	if ( ! m ) {
		return null;
	}
	const raw = String( m[ 1 ] || '' )
		.trim()
		.toLowerCase()
		.replace( /["']/g, '' );
	if ( raw === 'bold' ) {
		return '700';
	}
	if ( raw === 'normal' ) {
		return '400';
	}
	if ( /^(100|200|300|400|500|600|700|800|900)$/.test( raw ) ) {
		return raw;
	}
	return null;
}

/**
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeLineHeight( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	const m = style.match( /(?:^|;)\s*line-height\s*:\s*([^;]+)/i );
	if ( ! m ) {
		return null;
	}
	const raw = String( m[ 1 ] || '' )
		.trim()
		.toLowerCase()
		.replace( /["']/g, '' );
	if ( ! raw || raw === 'normal' || raw === 'inherit' ) {
		return null;
	}
	if ( /^[\d.]+$/.test( raw ) ) {
		const n = parseFloat( raw );
		return Number.isFinite( n ) && n > 0 && n <= 10 ? String( n ) : null;
	}
	if ( /^[\d.]+\s*(px|rem|%)$/i.test( raw ) ) {
		const compact = raw.replace( /\s/g, '' );
		const n = parseFloat( compact );
		if ( ! Number.isFinite( n ) || n <= 0 ) {
			return null;
		}
		if ( compact.endsWith( '%' ) && n > 1000 ) {
			return null;
		}
		if (
			( compact.endsWith( 'px' ) || compact.endsWith( 'rem' ) ) &&
			n > 500
		) {
			return null;
		}
		return compact.slice( 0, 24 );
	}
	return null;
}

/**
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeLetterSpacing( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	const m = style.match( /(?:^|;)\s*letter-spacing\s*:\s*([^;]+)/i );
	if ( ! m ) {
		return null;
	}
	const raw = String( m[ 1 ] || '' )
		.trim()
		.toLowerCase()
		.replace( /["']/g, '' );
	if ( ! raw || raw === 'normal' || raw === 'inherit' ) {
		return null;
	}
	if ( raw === '0' ) {
		return '0';
	}
	if ( /^-?[\d.]+\s*(px|rem|em)?$/.test( raw ) ) {
		const compact = raw.replace( /\s/g, '' );
		if ( /[a-z]$/i.test( compact ) ) {
			return compact.slice( 0, 24 );
		}
		const n = parseFloat( compact );
		return Number.isFinite( n ) ? `${ n }px`.slice( 0, 24 ) : null;
	}
	return null;
}

/**
 * Commands to show on the FormatToolbar for a field.
 * Always appends clear-format when any formatting is allowed.
 *
 * @param {object} [field]
 * @param {string[]|null} [override]
 * @return {string[]|null} null = full rich toolbar set; [] = none
 */
export function toolbarCommandsFromField( field, override = undefined ) {
	if ( isAdvanceEditorDisabled( field ) ) {
		return [];
	}
	const raw =
		override !== undefined
			? normalizeAllowedCommands( override )
			: allowedCommandsFromField( field );
	if ( raw == null ) {
		return null;
	}
	if ( ! raw.length ) {
		return [];
	}
	const out = [ ...raw ];
	if ( ! out.includes( 'removeformat' ) ) {
		out.push( 'removeformat' );
	}
	return out;
}

/**
 * Whether a toolbar command is active for the current command list.
 *
 * @param {string[]|null} commands
 * @param {string} cmd
 * @return {boolean}
 */
export function toolbarHasCommand( commands, cmd ) {
	if ( commands == null ) {
		return true;
	}
	return Array.isArray( commands ) && commands.includes( cmd );
}

/**
 * Extract text-align if allowed.
 *
 * @param {Element} el
 * @return {string|null}
 */
function extractSafeAlign( el ) {
	const style = el.getAttribute?.( 'style' ) || '';
	const m = style.match( /(?:^|;)\s*text-align\s*:\s*(left|center|right|justify)\b/i );
	return m ? m[ 1 ].toLowerCase() : null;
}

/**
 * Decode common HTML entities / strip control chars before href checks.
 *
 * @param {string} raw
 * @return {string}
 */
function normalizeHrefCandidate( raw ) {
	let s = String( raw || '' )
		.replace( /\u0000/g, '' )
		.trim();
	// Decode a few entities attackers use to hide schemes.
	s = s
		.replace( /&colon;/gi, ':' )
		.replace( /&#0*58;/g, ':' )
		.replace( /&#x0*3a;/gi, ':' )
		.replace( /&Tab;/gi, '' )
		.replace( /&NewLine;/gi, '' )
		.replace( /&#(x09|9);/gi, '' )
		.replace( /&#(x0a|10);/gi, '' )
		.replace( /&#(x0d|13);/gi, '' );
	// Strip whitespace / control chars often used to obfuscate javascript:
	s = s.replace( /[\u0000-\u001F\u007F\s]+/g, '' );
	return s;
}

/**
 * Safe http(s)/mailto/tel/anchor/relative href — blocks javascript/data/etc.
 *
 * @param {string|null} href
 * @return {string|null}
 */
export function sanitizeHref( href ) {
	const original = String( href || '' )
		.replace( /\u0000/g, '' )
		.trim();
	if ( ! original ) {
		return null;
	}
	const probe = normalizeHrefCandidate( original ).toLowerCase();
	if (
		probe.startsWith( 'javascript:' ) ||
		probe.startsWith( 'data:' ) ||
		probe.startsWith( 'vbscript:' ) ||
		probe.startsWith( 'blob:' ) ||
		probe.startsWith( 'file:' )
	) {
		return null;
	}
	if (
		/^https?:\/\//i.test( original ) ||
		original.startsWith( '/' ) ||
		original.startsWith( '#' ) ||
		/^mailto:/i.test( original ) ||
		/^tel:/i.test( original )
	) {
		return original.slice( 0, 2048 );
	}
	// Relative paths without a scheme.
	if ( ! /^[a-z][a-z0-9+.-]*:/i.test( original ) ) {
		return original.slice( 0, 2048 );
	}
	return null;
}

/**
 * Strip comments / conditional comments / null bytes before DOM parse.
 *
 * @param {string} html
 * @return {string}
 */
export function preprocessRawHtml( html ) {
	let raw = String( html ?? '' ).replace( /\u0000/g, '' );
	if ( ! raw ) {
		return '';
	}
	raw = raw.replace( /<!--[\s\S]*?-->/g, '' );
	raw = raw.replace( /<!\[CDATA\[[\s\S]*?\]\]>/gi, '' );
	// IE conditional comments.
	raw = raw.replace( /<!\[if[\s\S]*?<!\[endif\]>/gi, '' );
	return raw;
}

/**
 * Sanitize HTML to tags/attrs allowed by allowedCommand.
 *
 * @param {string} html
 * @param {string[]|null|unknown} allowedCommand
 * @param {{ allowInlineStyles?: boolean }} [options]
 *        allowInlineStyles — when false (paste), never emit style attributes.
 * @return {string}
 */
export function sanitizeAllowedHtml(
	html,
	allowedCommand = null,
	options = {}
) {
	const allowInlineStyles = options?.allowInlineStyles !== false;
	const commands = Array.isArray( allowedCommand )
		? allowedCommand
		: normalizeAllowedCommands( allowedCommand );
	const {
		tags,
		allowColor,
		allowLink,
		allowAlign,
		allowFontFamily,
		allowFontSize,
		allowFontWeight,
		allowLineHeight,
		allowLetterSpacing,
	} = allowedTagsFromCommands( commands );

	const raw = preprocessRawHtml( html );
	if ( ! raw ) {
		return '';
	}

	if ( typeof document === 'undefined' ) {
		// SSR / Node: strip tags naively for safety.
		return raw.replace( /<[^>]*>/g, '' );
	}

	const parser = new DOMParser();
	const doc = parser.parseFromString(
		`<div id="bm-sanitize-root">${ raw }</div>`,
		'text/html'
	);
	const root = doc.getElementById( 'bm-sanitize-root' );
	if ( ! root ) {
		return '';
	}

	/**
	 * @param {Node} node
	 * @return {Node|DocumentFragment|null}
	 */
	const clean = ( node ) => {
		if ( node.nodeType === Node.TEXT_NODE ) {
			return doc.createTextNode( node.textContent || '' );
		}
		if ( node.nodeType === Node.DOCUMENT_FRAGMENT_NODE ) {
			const frag = doc.createDocumentFragment();
			Array.from( node.childNodes ).forEach( ( child ) => {
				const c = clean( child );
				if ( c ) {
					frag.appendChild( c );
				}
			} );
			return frag;
		}
		if ( node.nodeType !== Node.ELEMENT_NODE ) {
			return null;
		}

		const el = /** @type {Element} */ ( node );
		const tag = el.tagName.toLowerCase();

		// Never keep media / scripts / forms — drop the whole subtree.
		if ( ALWAYS_DROP.has( tag ) ) {
			return null;
		}

		// font → span when color / font-family allowed (and styles permitted).
		if ( tag === 'font' ) {
			const color =
				allowInlineStyles && allowColor
					? extractSafeColor( el )
					: null;
			const face =
				allowInlineStyles && allowFontFamily
					? String( el.getAttribute( 'face' ) || '' )
							.replace( /[<>;{}]/g, '' )
							.trim()
							.slice( 0, 120 )
					: '';
			const styles = [];
			if ( color ) {
				styles.push( `color: ${ color }` );
			}
			if ( face ) {
				styles.push( `font-family: ${ face }` );
			}
			const frag = doc.createDocumentFragment();
			if ( styles.length ) {
				const span = doc.createElement( 'span' );
				span.setAttribute( 'style', styles.join( '; ' ) );
				Array.from( el.childNodes ).forEach( ( child ) => {
					const c = clean( child );
					if ( c ) {
						span.appendChild( c );
					}
				} );
				frag.appendChild( span );
			} else {
				Array.from( el.childNodes ).forEach( ( child ) => {
					const c = clean( child );
					if ( c ) {
						frag.appendChild( c );
					}
				} );
			}
			return frag;
		}

		const unwrapChildren = () => {
			const frag = doc.createDocumentFragment();
			Array.from( el.childNodes ).forEach( ( child ) => {
				const c = clean( child );
				if ( c ) {
					frag.appendChild( c );
				}
			} );
			return frag;
		};

		/**
		 * Enter / paste often yields <div>/<p>. If those tags are not allowed,
		 * keep a <br> so line breaks are not glued together.
		 */
		const unwrapBlockAsBreaks = () => {
			const frag = doc.createDocumentFragment();
			const inner = unwrapChildren();
			const hasContent =
				Boolean( inner.childNodes?.length ) ||
				Boolean(
					( el.textContent || '' ).replace( /\u00a0/g, ' ' ).trim()
				) ||
				Boolean( el.querySelector?.( 'br,hr' ) );
			if ( inner.childNodes?.length ) {
				frag.appendChild( inner );
			}
			if ( hasContent || ! frag.childNodes.length ) {
				frag.appendChild( doc.createElement( 'br' ) );
			}
			return frag;
		};

		if ( ! tags.has( tag ) ) {
			if (
				tag === 'p' ||
				tag === 'div' ||
				tag === 'h1' ||
				tag === 'h2' ||
				tag === 'h3' ||
				tag === 'h4' ||
				tag === 'h5' ||
				tag === 'h6' ||
				tag === 'li' ||
				tag === 'tr' ||
				tag === 'blockquote' ||
				tag === 'section' ||
				tag === 'article'
			) {
				return unwrapBlockAsBreaks();
			}
			return unwrapChildren();
		}

		if ( VOID_TAGS.has( tag ) ) {
			return doc.createElement( tag );
		}

		const out = doc.createElement( tag );

		if (
			allowInlineStyles &&
			tag === 'span' &&
			( allowColor ||
				allowFontFamily ||
				allowFontSize ||
				allowFontWeight ||
				allowLineHeight ||
				allowLetterSpacing )
		) {
			const styles = [];
			if ( allowColor ) {
				const color = extractSafeColor( el );
				if ( color ) {
					styles.push( `color: ${ color }` );
				}
			}
			if ( allowFontFamily ) {
				const family = extractSafeFontFamily( el );
				if ( family ) {
					styles.push( `font-family: ${ family }` );
				}
			}
			if ( allowFontSize ) {
				const size = extractSafeFontSize( el );
				if ( size ) {
					styles.push( `font-size: ${ size }` );
				}
			}
			if ( allowFontWeight ) {
				const weight = extractSafeFontWeight( el );
				if ( weight ) {
					styles.push( `font-weight: ${ weight }` );
				}
			}
			if ( allowLineHeight ) {
				const lh = extractSafeLineHeight( el );
				if ( lh ) {
					styles.push( `line-height: ${ lh }` );
					// Keep block display so line-height works outside headings.
					styles.push( 'display: block' );
				}
			}
			if ( allowLetterSpacing ) {
				const ls = extractSafeLetterSpacing( el );
				if ( ls ) {
					styles.push( `letter-spacing: ${ ls }` );
				}
			}
			if ( styles.length ) {
				out.setAttribute( 'style', styles.join( '; ' ) );
			}
		}

		// Library / editor may put color on strong/em wrappers — keep it through sanitize/import.
		if (
			allowInlineStyles &&
			allowColor &&
			( tag === 'strong' ||
				tag === 'b' ||
				tag === 'em' ||
				tag === 'i' ||
				tag === 'u' ||
				tag === 's' ||
				tag === 'strike' ||
				tag === 'del' )
		) {
			const color = extractSafeColor( el );
			if ( color ) {
				out.setAttribute( 'style', `color: ${ color }` );
			}
		}

		if (
			allowInlineStyles &&
			( tag === 'h1' ||
				tag === 'h2' ||
				tag === 'h3' ||
				tag === 'h4' ||
				tag === 'h5' ||
				tag === 'h6' ) &&
			( allowLineHeight || allowLetterSpacing )
		) {
			const styles = [];
			if ( allowLineHeight ) {
				const lh = extractSafeLineHeight( el );
				if ( lh ) {
					styles.push( `line-height: ${ lh }` );
				}
			}
			if ( allowLetterSpacing ) {
				const ls = extractSafeLetterSpacing( el );
				if ( ls ) {
					styles.push( `letter-spacing: ${ ls }` );
				}
			}
			if ( styles.length ) {
				out.setAttribute( 'style', styles.join( '; ' ) );
			}
		}

		if (
			allowInlineStyles &&
			tag === 'span' &&
			allowAlign
		) {
			const align = extractSafeAlign( el );
			if ( align ) {
				const existing = out.getAttribute( 'style' ) || '';
				const next = [ existing, `text-align: ${ align }` ]
					.filter( Boolean )
					.join( '; ' );
				out.setAttribute( 'style', next );
			}
		}

		if ( tag === 'a' && allowLink ) {
			const href = sanitizeHref( el.getAttribute( 'href' ) );
			if ( href ) {
				out.setAttribute( 'href', href );
				const target = el.getAttribute( 'target' );
				if ( target === '_blank' ) {
					out.setAttribute( 'target', '_blank' );
					out.setAttribute( 'rel', 'noopener noreferrer' );
				}
			} else {
				return unwrapChildren();
			}
		}

		Array.from( el.childNodes ).forEach( ( child ) => {
			const c = clean( child );
			if ( c ) {
				out.appendChild( c );
			}
		} );

		// Drop empty formatting wrappers (keep void tags).
		if (
			! VOID_TAGS.has( tag ) &&
			! out.textContent?.replace( /\u00a0/g, ' ' ).trim() &&
			! out.querySelector( 'br,hr' )
		) {
			return null;
		}

		return out;
	};

	const result = doc.createElement( 'div' );
	Array.from( root.childNodes ).forEach( ( child ) => {
		const c = clean( child );
		if ( c ) {
			result.appendChild( c );
		}
	} );

	if ( allowInlineStyles ) {
		// Never keep style spans nested (color/font siblings only).
		explodeNestedStyleSpans( result );
	}

	return result.innerHTML;
}

/**
 * Paste sanitizer: allowed tags only, no style attributes, no media/scripts.
 *
 * @param {string} html
 * @param {string[]|null|unknown} allowedCommand
 * @return {string}
 */
export function sanitizePasteHtml( html, allowedCommand = null ) {
	return sanitizeAllowedHtml( html, allowedCommand, {
		allowInlineStyles: false,
	} );
}

/**
 * Style-only span: only a `style` attribute (sanitized markup).
 *
 * @param {Node|null} node
 * @return {boolean}
 */
function isSanitizedStyleSpan( node ) {
	if ( ! node || node.nodeType !== Node.ELEMENT_NODE ) {
		return false;
	}
	const el = /** @type {Element} */ ( node );
	if ( el.tagName !== 'SPAN' ) {
		return false;
	}
	const names = el.getAttributeNames?.() || [];
	return names.length === 1 && names[ 0 ] === 'style';
}

/**
 * @param {Element} el
 * @return {{ color: string, fontFamily: string, fontSize: string, fontWeight: string, lineHeight: string, letterSpacing: string }}
 */
function readSpanTextStyles( el ) {
	const style = /** @type {HTMLElement} */ ( el ).style;
	const rawWeight = String( style?.fontWeight || '' )
		.trim()
		.toLowerCase();
	let fontWeight = '';
	if ( rawWeight === 'bold' ) {
		fontWeight = '700';
	} else if ( rawWeight === 'normal' ) {
		fontWeight = '400';
	} else if (
		/^(100|200|300|400|500|600|700|800|900)$/.test( rawWeight )
	) {
		fontWeight = rawWeight;
	}
	return {
		color: String( style?.color || '' ).trim(),
		fontFamily: String( style?.fontFamily || '' ).trim(),
		fontSize: String( style?.fontSize || '' ).trim(),
		fontWeight,
		lineHeight: String( style?.lineHeight || '' ).trim(),
		letterSpacing: String( style?.letterSpacing || '' ).trim(),
	};
}

/**
 * @param {Element} el
 * @param {{ color?: string, fontFamily?: string, fontSize?: string, fontWeight?: string, lineHeight?: string, letterSpacing?: string }} props
 */
function writeSpanTextStyles( el, props ) {
	const htmlEl = /** @type {HTMLElement} */ ( el );
	const color = String( props.color || '' ).trim();
	const fontFamily = String( props.fontFamily || '' ).trim();
	const fontSize = String( props.fontSize || '' ).trim();
	const fontWeight = String( props.fontWeight || '' ).trim();
	const lineHeight = String( props.lineHeight || '' ).trim();
	const letterSpacing = String( props.letterSpacing || '' ).trim();
	const parts = [];
	if ( color ) {
		parts.push( `color: ${ color }` );
	}
	if ( fontFamily ) {
		parts.push( `font-family: ${ fontFamily }` );
	}
	if ( fontSize ) {
		parts.push( `font-size: ${ fontSize }` );
	}
	if ( fontWeight ) {
		parts.push( `font-weight: ${ fontWeight }` );
	}
	if ( lineHeight ) {
		parts.push( `line-height: ${ lineHeight }` );
		parts.push( 'display: block' );
	}
	if ( letterSpacing ) {
		parts.push( `letter-spacing: ${ letterSpacing }` );
	}
	if ( parts.length ) {
		htmlEl.setAttribute( 'style', parts.join( '; ' ) );
	} else {
		htmlEl.removeAttribute( 'style' );
	}
}

/**
 * @param {Element} parent
 * @param {Element} child
 */
function splitStyleParentAroundChild( parent, child ) {
	const host = parent.parentNode;
	if ( ! host || child.parentNode !== parent ) {
		return;
	}
	const parentStyles = readSpanTextStyles( parent );
	const beforeFrag = docFragmentFrom( parent, child, 'before' );
	parent.removeChild( child );
	const afterFrag = docFragmentFrom( parent, null, 'after' );

	const wrap = ( frag ) => {
		if ( ! frag.childNodes.length ) {
			return null;
		}
		if (
			! parentStyles.color &&
			! parentStyles.fontFamily &&
			! parentStyles.fontSize &&
			! parentStyles.fontWeight &&
			! parentStyles.lineHeight &&
			! parentStyles.letterSpacing
		) {
			return frag;
		}
		const span = parent.ownerDocument.createElement( 'span' );
		writeSpanTextStyles( span, parentStyles );
		span.appendChild( frag );
		return span;
	};

	const beforeNode = wrap( beforeFrag );
	const childStyles = readSpanTextStyles( child );
	writeSpanTextStyles( child, {
		color: childStyles.color || parentStyles.color,
		fontFamily: childStyles.fontFamily || parentStyles.fontFamily,
		fontSize: childStyles.fontSize || parentStyles.fontSize,
		fontWeight: childStyles.fontWeight || parentStyles.fontWeight,
		lineHeight: childStyles.lineHeight || parentStyles.lineHeight,
		letterSpacing:
			childStyles.letterSpacing || parentStyles.letterSpacing,
	} );
	const afterNode = wrap( afterFrag );

	if ( beforeNode ) {
		host.insertBefore( beforeNode, parent );
	}
	host.insertBefore( child, parent );
	if ( afterNode ) {
		host.insertBefore( afterNode, parent );
	}
	host.removeChild( parent );
}

/**
 * @param {Element} parent
 * @param {Node|null} stop
 * @param {'before'|'after'} mode
 * @return {DocumentFragment}
 */
function docFragmentFrom( parent, stop, mode ) {
	const frag = parent.ownerDocument.createDocumentFragment();
	if ( mode === 'before' ) {
		while ( parent.firstChild && parent.firstChild !== stop ) {
			frag.appendChild( parent.firstChild );
		}
	} else {
		while ( parent.firstChild ) {
			frag.appendChild( parent.firstChild );
		}
	}
	return frag;
}

/**
 * Convert nested style spans into siblings.
 *
 * @param {ParentNode} root
 */
function explodeNestedStyleSpans( root ) {
	if ( ! root?.querySelectorAll ) {
		return;
	}
	let guard = 0;
	while ( guard++ < 40 ) {
		const candidates = Array.from( root.querySelectorAll( 'span' ) ).filter(
			isSanitizedStyleSpan
		);
		candidates.sort( ( a, b ) => {
			let da = 0;
			let db = 0;
			let n = a;
			while ( n && n !== root ) {
				da += 1;
				n = n.parentElement;
			}
			n = b;
			while ( n && n !== root ) {
				db += 1;
				n = n.parentElement;
			}
			return db - da;
		} );
		const child = candidates.find( ( span ) =>
			isSanitizedStyleSpan( span.parentElement )
		);
		if ( ! child ) {
			break;
		}
		splitStyleParentAroundChild(
			/** @type {Element} */ ( child.parentElement ),
			/** @type {Element} */ ( child )
		);
	}

	// Merge adjacent identical style spans.
	let changed = true;
	let mergeGuard = 0;
	while ( changed && mergeGuard++ < 40 ) {
		changed = false;
		const spans = Array.from( root.querySelectorAll( 'span' ) ).filter(
			isSanitizedStyleSpan
		);
		for ( const span of spans ) {
			const next = span.nextSibling;
			if ( ! isSanitizedStyleSpan( next ) ) {
				continue;
			}
			const a = readSpanTextStyles( span );
			const b = readSpanTextStyles( /** @type {Element} */ ( next ) );
			if (
				a.color !== b.color ||
				a.fontFamily !== b.fontFamily ||
				a.fontSize !== b.fontSize ||
				a.fontWeight !== b.fontWeight ||
				a.lineHeight !== b.lineHeight ||
				a.letterSpacing !== b.letterSpacing
			) {
				continue;
			}
			while ( next.firstChild ) {
				span.appendChild( next.firstChild );
			}
			next.parentNode?.removeChild( next );
			span.normalize?.();
			changed = true;
			break;
		}
	}
}
