/*!
 * SVG/attribute allowlists and URI sanitization approach derived from DOMPurify.
 * DOMPurify — MIT License, Copyright (c) 2015 Mario Heiderich
 * https://github.com/cure53/DOMPurify/blob/main/LICENSE
 */

// --- Allowed SVG elements (lowercase) ---
// Includes foreignObject, style, and animation elements.
const ALLOWED_SVG_TAGS = new Set([
	'svg',
	'a',
	'altglyph',
	'altglyphdef',
	'altglyphitem',
	'animate',
	'animatecolor',
	'animatemotion',
	'animatetransform',
	'circle',
	'clippath',
	'defs',
	'desc',
	'ellipse',
	'feblend',
	'fecolormatrix',
	'fecomponenttransfer',
	'fecomposite',
	'feconvolvematrix',
	'fediffuselighting',
	'fedisplacementmap',
	'fedistantlight',
	'fedropshadow',
	'feflood',
	'fefunca',
	'fefuncb',
	'fefuncg',
	'fefuncr',
	'fegaussianblur',
	'feimage',
	'femerge',
	'femergenode',
	'femorphology',
	'feoffset',
	'fepointlight',
	'fespecularlighting',
	'fespotlight',
	'fetile',
	'feturbulence',
	'filter',
	'font',
	'foreignobject',
	'g',
	'glyph',
	'glyphref',
	'hkern',
	'image',
	'line',
	'lineargradient',
	'marker',
	'mask',
	'metadata',
	'mpath',
	'path',
	'pattern',
	'polygon',
	'polyline',
	'radialgradient',
	'rect',
	'set',
	'stop',
	'style',
	'switch',
	'symbol',
	'text',
	'textpath',
	'title',
	'tref',
	'tspan',
	'use',
	'view',
	'vkern',
])

// --- Allowed HTML elements inside foreignObject ---
const ALLOWED_HTML_TAGS = new Set([
	'a',
	'b',
	'blockquote',
	'body',
	'br',
	'code',
	'del',
	'div',
	'em',
	'h1',
	'h2',
	'h3',
	'h4',
	'h5',
	'h6',
	'i',
	'li',
	'mark',
	'ol',
	'p',
	'pre',
	'span',
	'strong',
	's',
	'sub',
	'sup',
	'table',
	'tbody',
	'td',
	'th',
	'thead',
	'tr',
	'u',
	'ul',
])

// --- Blocked HTML elements inside foreignObject ---
// These are explicitly dangerous even if not in the allow list (defense in depth).
const BLOCKED_HTML_TAGS = new Set([
	'script',
	'iframe',
	'object',
	'embed',
	'form',
	'input',
	'textarea',
	'select',
	'button',
	'link',
	'meta',
	'base',
	'img', // onerror vector
	'video',
	'audio',
	'source',
	'picture',
	'svg', // no nested SVG inside foreignObject
])

// --- Allowed SVG attributes (lowercase) ---
const ALLOWED_SVG_ATTRS = new Set([
	'accent-height',
	'accumulate',
	'additive',
	'alignment-baseline',
	'amplitude',
	'ascent',
	'attributename',
	'attributetype',
	'azimuth',
	'basefrequency',
	'baseline-shift',
	'begin',
	'bias',
	'by',
	'class',
	'clip',
	'clip-path',
	'clip-rule',
	'clippathunits',
	'color',
	'color-interpolation',
	'color-interpolation-filters',
	'color-profile',
	'color-rendering',
	'cx',
	'cy',
	'd',
	'diffuseconstant',
	'direction',
	'display',
	'divisor',
	'dominant-baseline',
	'dur',
	'dx',
	'dy',
	'edgemode',
	'elevation',
	'end',
	'exponent',
	'fill',
	'fill-opacity',
	'fill-rule',
	'filter',
	'filterunits',
	'flood-color',
	'flood-opacity',
	'font-family',
	'font-size',
	'font-size-adjust',
	'font-stretch',
	'font-style',
	'font-variant',
	'font-weight',
	'from',
	'fx',
	'fy',
	'g1',
	'g2',
	'glyph-name',
	'glyphref',
	'gradienttransform',
	'gradientunits',
	'height',
	'href',
	'id',
	'image-rendering',
	'in',
	'in2',
	'intercept',
	'k',
	'k1',
	'k2',
	'k3',
	'k4',
	'kerning',
	'kernelmatrix',
	'kernelunitlength',
	'keypoints',
	'keysplines',
	'keytimes',
	'lang',
	'lengthadjust',
	'letter-spacing',
	'lighting-color',
	'local',
	'marker-end',
	'marker-mid',
	'marker-start',
	'markerheight',
	'markerunits',
	'markerwidth',
	'mask',
	'mask-type',
	'maskcontentunits',
	'maskunits',
	'max',
	'media',
	'method',
	'min',
	'mode',
	'name',
	'numoctaves',
	'offset',
	'opacity',
	'operator',
	'order',
	'orient',
	'orientation',
	'origin',
	'overflow',
	'paint-order',
	'path',
	'pathlength',
	'patterncontentunits',
	'patterntransform',
	'patternunits',
	'pointer-events',
	'points',
	'preservealpha',
	'preserveaspectratio',
	'primitiveunits',
	'r',
	'radius',
	'refx',
	'refy',
	'repeatcount',
	'repeatdur',
	'requiredfeatures',
	'restart',
	'result',
	'role',
	'rotate',
	'rx',
	'ry',
	'scale',
	'seed',
	'shape-rendering',
	'slope',
	'specularconstant',
	'specularexponent',
	'spreadmethod',
	'startoffset',
	'stddeviation',
	'stitchtiles',
	'stop-color',
	'stop-opacity',
	'stroke',
	'stroke-dasharray',
	'stroke-dashoffset',
	'stroke-linecap',
	'stroke-linejoin',
	'stroke-miterlimit',
	'stroke-opacity',
	'stroke-width',
	'style',
	'surfacescale',
	'systemlanguage',
	'tabindex',
	'tablevalues',
	'targetx',
	'targety',
	'text-anchor',
	'text-decoration',
	'text-rendering',
	'textlength',
	'to',
	'transform',
	'transform-origin',
	'type',
	'u1',
	'u2',
	'unicode',
	'values',
	'version',
	'vert-adv-y',
	'vert-origin-x',
	'vert-origin-y',
	'viewbox',
	'visibility',
	'width',
	'word-spacing',
	'wrap',
	'writing-mode',
	'x',
	'x1',
	'x2',
	'xchannelselector',
	'xlink:href',
	'xml:id',
	'xml:space',
	'xlink:title',
	'xmlns',
	'xmlns:xlink',
	'y',
	'y1',
	'y2',
	'z',
	'zoomandpan',
])

// --- Allowed HTML attributes inside foreignObject ---
const ALLOWED_HTML_ATTRS = new Set([
	'class',
	'dir',
	'href', // only on <a>
	'id',
	'lang',
	'role',
	'style',
	'tabindex',
	'title',
])

// Patterns for data-* and aria-* attributes
const DATA_ATTR_PATTERN = /^data-/
const ARIA_ATTR_PATTERN = /^aria-/

// --- URI sanitization ---
// Strip invisible whitespace that can bypass protocol checks
// eslint-disable-next-line no-control-regex
const INVISIBLE_WHITESPACE = /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g

// Safe link protocols
const SAFE_LINK_PROTOCOLS = /^(?:https?:|mailto:)/i

// data: URI (for images, fonts)
const DATA_URI = /^data:/i

// data: URI for raster image formats (svg+xml handled separately below)
const RASTER_DATA_URI =
	/^data:image\/(?:png|jpeg|jpg|gif|webp|avif|bmp|tiff|x-icon|vnd\.microsoft\.icon)[;,]/i

// data: URI for SVG images — allowed on <image>/<feimage> only after recursive sanitization
const SVG_DATA_URI = /^data:image\/svg\+xml[;,]/i

// Fragment-only ref (#id)
const FRAGMENT_REF = /^#/

// --- CSS sanitization ---

function decodeCssEscapes(css: string): string {
	return css.replace(/\\([0-9a-fA-F]{1,6})\s?|\\([^\n])/g, (_, hex, literal) => {
		if (hex) {
			const codePoint = parseInt(hex, 16)
			if (codePoint > 0x10ffff || codePoint === 0) return '\uFFFD'
			return String.fromCodePoint(codePoint)
		}
		return literal
	})
}

// Allowed data: MIME types in CSS url()
const SAFE_CSS_DATA_MIME =
	/^data:(?:image\/(?:png|jpeg|jpg|gif|webp|avif)|font\/(?:woff2?|opentype|truetype|sfnt)|application\/(?:x-font-woff|font-woff2?|x-font-ttf|x-font-opentype|font-sfnt))[;,]/i

function sanitizeCssValue(css: string): string {
	let decoded = decodeCssEscapes(css)
	// Strip @import — handle url() with semicolons inside quotes
	decoded = decoded.replace(
		/@import\s+(?:url\s*\([^)]*\)|"[^"]*"|'[^']*')[^;]*;?|@import\b[^;]*;?/gi,
		''
	)
	// Strip expression(), -moz-binding, behavior:
	decoded = decoded.replace(/expression\s*\([^)]*\)/gi, '')
	decoded = decoded.replace(/-moz-binding\s*:[^;]*/gi, '')
	decoded = decoded.replace(/behavior\s*:[^;]*/gi, '')
	// Sanitize url() — allow only data: with safe MIME types
	decoded = decoded.replace(/url\s*\(\s*(['"]?)(.*?)\1\s*\)/gis, (match, _quote, uri) => {
		const stripped = uri.replace(INVISIBLE_WHITESPACE, '')
		if (SAFE_CSS_DATA_MIME.test(stripped) || FRAGMENT_REF.test(stripped)) {
			return match
		}
		return ''
	})
	return decoded
}

function sanitizeStyleElement(textContent: string): string {
	return sanitizeCssValue(textContent)
}

// --- Animation safety ---
// Animation elements (<animate>, <set>) can overwrite attributes at runtime.
// If attributeName targets a URI attr (href) or event handler (on*), the animation
// can inject javascript: URIs or re-add stripped handlers, bypassing static sanitization.
const ANIMATION_TAGS = new Set(['animate', 'set', 'animatecolor', 'animatetransform'])
const DANGEROUS_ANIMATION_TARGETS = /^(?:href|xlink:href|on)/i

function isAnimationDangerous(el: Element): boolean {
	const attrName = el.getAttribute('attributeName')
	if (!attrName) return false
	return DANGEROUS_ANIMATION_TARGETS.test(attrName.replace(INVISIBLE_WHITESPACE, ''))
}

// --- Event handler detection ---
// Matches on* after normalizing invisible chars — catches all current and future event handlers
const EVENT_HANDLER_PATTERN = /^on/i

// --- SVG presentation attributes that accept url() references ---
// These can exfiltrate data via external URL loads if not sanitized.
const URL_BEARING_SVG_ATTRS = new Set([
	'clip-path',
	'cursor',
	'fill',
	'filter',
	'marker-end',
	'marker-mid',
	'marker-start',
	'mask',
	'stroke',
])

// --- Node sanitization ---

type SanitizeMode = 'svg' | 'html'

// Guard against deep recursion: sanitizeSvgInner → ... → sanitizeUri → sanitizeEmbeddedSvgDataUri → sanitizeSvgInner
const MAX_EMBED_DEPTH = 10

function decodeDataUri(value: string): string | null {
	const base64Idx = value.search(/;base64,/i)
	if (base64Idx >= 0) {
		const base64 = value.slice(base64Idx + 8) // 8 = ";base64,".length
		const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))
		return new TextDecoder().decode(bytes)
	}
	const commaIdx = value.indexOf(',')
	if (commaIdx < 0) return null
	return decodeURIComponent(value.slice(commaIdx + 1))
}

function encodeAsSvgDataUri(svgText: string): string {
	const bytes = new TextEncoder().encode(svgText)
	const binaryStr = Array.from(bytes, (b) => String.fromCharCode(b)).join('')
	return 'data:image/svg+xml;base64,' + btoa(binaryStr)
}

function sanitizeEmbeddedSvgDataUri(value: string, depth: number): string | null {
	if (depth >= MAX_EMBED_DEPTH) {
		console.warn(`Embedded SVG data URI recursion depth limit (${MAX_EMBED_DEPTH}) reached`)
		return null
	}
	let svgText: string
	try {
		const decoded = decodeDataUri(value)
		if (decoded === null) return null
		svgText = decoded
	} catch {
		// Invalid base64 or malformed percent-encoding — treat as unsafe
		return null
	}
	const sanitized = sanitizeSvgInner(svgText, depth + 1)
	if (!sanitized) return null
	return encodeAsSvgDataUri(sanitized)
}

function sanitizeUri(el: Element, attrName: string, value: string, depth: number): string | null {
	const stripped = value.replace(INVISIBLE_WHITESPACE, '')
	const tagName = el.tagName.toLowerCase()

	// <image> and <feImage>: raster data: or recursively-sanitized svg+xml data:
	if (tagName === 'image' || tagName === 'feimage') {
		if (RASTER_DATA_URI.test(stripped)) return value
		if (SVG_DATA_URI.test(stripped)) return sanitizeEmbeddedSvgDataUri(stripped, depth)
		return null
	}

	// <use>: fragment-only (#id)
	if (tagName === 'use') {
		if (FRAGMENT_REF.test(stripped)) return value
		return null
	}

	// <a>: http, https, mailto only
	if (tagName === 'a') {
		if (SAFE_LINK_PROTOCOLS.test(stripped)) return value
		return null
	}

	// All other elements with href/xlink:href: data: or fragment
	if (DATA_URI.test(stripped) || FRAGMENT_REF.test(stripped)) return value
	return null
}

function sanitizeSvgAttributes(el: Element, depth: number): void {
	for (let i = el.attributes.length - 1; i >= 0; i--) {
		const attr = el.attributes[i]
		const name = attr.name.toLowerCase()

		// Strip ALL event handlers (on*)
		const normalized = name.replace(INVISIBLE_WHITESPACE, '')
		if (EVENT_HANDLER_PATTERN.test(normalized)) {
			el.removeAttribute(attr.name)
			continue
		}

		// Allow data-* and aria-*
		if (DATA_ATTR_PATTERN.test(name) || ARIA_ATTR_PATTERN.test(name)) {
			continue
		}

		if (!ALLOWED_SVG_ATTRS.has(name)) {
			el.removeAttribute(attr.name)
			continue
		}

		// URI attributes need context-dependent sanitization
		if (name === 'href' || name === 'xlink:href') {
			const sanitized = sanitizeUri(el, name, attr.value, depth)
			if (sanitized === null) {
				el.removeAttribute(attr.name)
			} else if (sanitized !== attr.value) {
				attr.value = sanitized
			}
			continue
		}

		// style attribute: sanitize CSS
		if (name === 'style') {
			attr.value = sanitizeCssValue(attr.value)
			continue
		}

		// Presentation attributes that accept url() references — sanitize to allow
		// only data: (safe MIME) and fragment (#id) refs, strip external URLs
		if (URL_BEARING_SVG_ATTRS.has(name) && /url\s*\(/i.test(attr.value)) {
			attr.value = sanitizeCssValue(attr.value)
		}
	}
}

function sanitizeHtmlAttributes(el: Element): void {
	const tagName = el.tagName.toLowerCase()

	for (let i = el.attributes.length - 1; i >= 0; i--) {
		const attr = el.attributes[i]
		const name = attr.name.toLowerCase()

		// Strip ALL event handlers
		const normalized = name.replace(INVISIBLE_WHITESPACE, '')
		if (EVENT_HANDLER_PATTERN.test(normalized)) {
			el.removeAttribute(attr.name)
			continue
		}

		// Allow data-* and aria-*
		if (DATA_ATTR_PATTERN.test(name) || ARIA_ATTR_PATTERN.test(name)) {
			continue
		}

		if (!ALLOWED_HTML_ATTRS.has(name)) {
			el.removeAttribute(attr.name)
			continue
		}

		// href only allowed on <a>, with safe protocols only
		if (name === 'href') {
			if (tagName !== 'a') {
				el.removeAttribute(attr.name)
				continue
			}
			const stripped = attr.value.replace(INVISIBLE_WHITESPACE, '')
			if (!SAFE_LINK_PROTOCOLS.test(stripped)) {
				el.removeAttribute(attr.name)
			}
			continue
		}

		// style attribute: sanitize CSS
		if (name === 'style') {
			attr.value = sanitizeCssValue(attr.value)
		}
	}
}

function sanitizeNode(node: Element, mode: SanitizeMode, depth: number): void {
	// Walk children in reverse so removals don't shift indices
	for (let i = node.children.length - 1; i >= 0; i--) {
		const child = node.children[i]
		const tag = child.tagName.toLowerCase()

		if (mode === 'svg') {
			if (tag === 'foreignobject') {
				// foreignObject: sanitize attrs as SVG, recurse children as HTML
				sanitizeSvgAttributes(child, depth)
				sanitizeNode(child, 'html', depth)
			} else if (tag === 'style') {
				// <style>: sanitize attrs, sanitize text content as CSS
				sanitizeSvgAttributes(child, depth)
				if (child.textContent) {
					child.textContent = sanitizeStyleElement(child.textContent)
				}
			} else if (ANIMATION_TAGS.has(tag) && isAnimationDangerous(child)) {
				// Animation targeting href/on* can inject javascript: URIs at runtime
				child.remove()
			} else if (ALLOWED_SVG_TAGS.has(tag)) {
				sanitizeSvgAttributes(child, depth)
				sanitizeNode(child, 'svg', depth)
			} else {
				child.remove()
			}
		} else {
			// HTML mode (inside foreignObject)
			if (BLOCKED_HTML_TAGS.has(tag)) {
				child.remove()
			} else if (ALLOWED_HTML_TAGS.has(tag)) {
				sanitizeHtmlAttributes(child)
				sanitizeNode(child, 'html', depth)
			} else {
				child.remove()
			}
		}
	}
}

function sanitizeSvgInner(svgText: string, depth: number): string {
	const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml')

	const parseError = doc.querySelector('parsererror')
	if (parseError) return ''

	const svg = doc.documentElement
	if (svg.tagName.toLowerCase() !== 'svg') return ''

	sanitizeSvgAttributes(svg, depth)
	sanitizeNode(svg, 'svg', depth)

	if (svg.children.length === 0) return ''

	return new XMLSerializer().serializeToString(svg)
}

/**
 * Sanitizes an SVG string by removing dangerous elements, attributes, and URIs
 * while preserving safe content including foreignObject (for text rendering),
 * style elements (for fonts with data: URLs), and animation elements.
 * Embedded SVG data URIs on `<image>`/`<feImage>` are recursively sanitized.
 *
 * Returns the sanitized SVG string, or an empty string if the input was
 * malformed (parse error) or contained no safe content after sanitization.
 *
 * @public
 */
export function sanitizeSvg(svgText: string): string {
	return sanitizeSvgInner(svgText, 0)
}
