import { toValue, type MaybeRef } from 'vue';
import xss from 'xss';

import { ALLOWED_HTML_ATTRIBUTES, ALLOWED_HTML_TAGS } from './constants/sanitization';

/*
	Constants and utility functions that help in HTML, CSS and DOM manipulation
*/

// `xss` is CJS with no `exports` map, so Node's lexer cannot see these as named
// exports and `import { ... }` throws at link time under native ESM.
const { escapeAttrValue, escapeHtml } = xss as unknown as {
	escapeAttrValue: (str: string) => string;
	escapeHtml: (str: string) => string;
};

/**
 * Escapes HTML entities in a string to prevent HTML injection.
 * This should be used for user input that will be displayed in HTML contexts.
 * Unlike sanitizeHtml, this completely escapes all HTML entities.
 */
export { escapeHtml };

export function sanitizeHtml(dirtyHtml: string) {
	const sanitizedHtml = xss(dirtyHtml, {
		onTagAttr: (tag, name, value) => {
			if (tag === 'img' && name === 'src') {
				// Only allow http requests to supported image files from the `static` directory
				const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null;
				const isStaticImageFile = isImageFile && value.startsWith('/static/');
				if (!value.startsWith('https://') && !isStaticImageFile) {
					return '';
				}
			}

			if (ALLOWED_HTML_ATTRIBUTES.includes(name) || name.startsWith('data-')) {
				// href is allowed but we allow only https and relative URLs
				if (name === 'href' && !value.match(/^https?:\/\//) && !value.startsWith('/')) {
					return '';
				}
				return `${name}="${escapeAttrValue(value)}"`;
			}

			return;
			// Return nothing, means keep the default handling measure
		},
		onTag: (tag) => {
			if (!ALLOWED_HTML_TAGS.includes(tag)) return '';
			return;
		},
	});

	return sanitizedHtml;
}

const SAFE_OPEN_PROTOCOLS = ['http:', 'https:'];

/**
 * Opens a URL in a new tab, but only when it resolves to an http(s) protocol.
 * Persisted, untrusted values (e.g. resource locator URLs) can carry schemes
 * like `javascript:` or `data:`, so anything else is dropped. Always opens with
 * `noopener,noreferrer` so the new tab cannot reach back to the opener window.
 */
export function openSafeUrl(url: string): void {
	let protocol: string;
	try {
		protocol = new URL(url, window.location.origin).protocol;
	} catch {
		return;
	}

	if (SAFE_OPEN_PROTOCOLS.includes(protocol)) {
		window.open(url, '_blank', 'noopener,noreferrer');
	}
}

/**
 * Checks if the input is a string and sanitizes it by removing or escaping harmful characters,
 * returning the original input if it's not a string.
 */
export const sanitizeIfString = <T>(message: T): string | T => {
	if (typeof message === 'string') {
		return sanitizeHtml(message);
	}
	return message;
};

export const capitalizeFirstLetter = (text: string): string => {
	return text.charAt(0).toUpperCase() + text.slice(1);
};

export const getBannerRowHeight = async (): Promise<number> => {
	return await new Promise((resolve) => {
		setTimeout(() => {
			resolve(document.getElementById('banners')?.clientHeight ?? 0);
		}, 0);
	});
};

export function isOutsideSelected(el: HTMLElement | null) {
	const selection = document.getSelection();

	if (!selection?.anchorNode || !selection.focusNode || !el) {
		return false;
	}

	return (
		!el.contains(selection.anchorNode) &&
		!el.contains(selection.focusNode) &&
		(selection.anchorNode !== selection.focusNode ||
			selection.anchorOffset !== selection.focusOffset)
	);
}

let scrollbarWidth: number | undefined;

export function getScrollbarWidth() {
	if (scrollbarWidth !== undefined) {
		return scrollbarWidth;
	}

	const outer = document.createElement('div');
	const inner = document.createElement('div');

	outer.style.visibility = 'hidden';
	outer.style.overflow = 'scroll';
	document.body.appendChild(outer);

	outer.appendChild(inner);

	scrollbarWidth = outer.offsetWidth - inner.offsetWidth;

	outer.parentElement?.removeChild(outer);

	return scrollbarWidth;
}

export function isEventTargetContainedBy(
	eventTarget: EventTarget | null | undefined,
	maybeContainer: MaybeRef<HTMLElement | null | undefined>,
): boolean {
	return !!(eventTarget instanceof Node && toValue(maybeContainer)?.contains(eventTarget));
}
