Ctrl K
ComunidadGuías…

src/Widget.svelte

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<svelte:options
	customElement={{
		tag: 'altcha-widget',
		shadow: 'none',
		props: {
			auto: {
				type: 'String'
			},
			challenge: {
				type: 'String'
			},
			configuration: {
				type: 'String'
			},
			display: {
				type: 'String'
			},
			form: {
				type: 'String'
			},
			language: {
				type: 'String'
			},
			name: {
				type: 'String'
			},
			theme: {
				type: 'String'
			},
			type: {
				type: 'String'
			},
			workers: {
				type: 'Number'
			}
		}
	}}
/>

<script lang="ts">
	import { onMount, tick } from 'svelte';
	import Checkbox from './components/Checkbox.svelte';
	import CheckboxNative from './components/CheckboxNative.svelte';
	import Footer from './components/Footer.svelte';
	import Switch from './components/Switch.svelte';
	import Code from './components/Code.svelte';
	import Popover from './components/Popover.svelte';
	import Logo from './components/Logo.svelte';
	import {
		State,
		type Challenge,
		type ChallengeParameters,
		type ChallengeV1,
		type CodeChallenge,
		type Configuration,
		type PayloadV1,
		type ServerVerificationResult,
		type SetCookieOptions,
		type Solution,
		type Strings,
		type VerifyOptions,
		type VerifyResult,
		type WidgetMethods
	} from './types/';
	import { solveChallengeWorkers } from './pow';
	import { bufferToHex } from './helpers';
	import { Collector } from './his';
	import type { BasePlugin } from './plugins/base.plugin';

	// -----------------------------------------------------------------------------
	// Props
	// -----------------------------------------------------------------------------

	interface Props extends Partial<
		Pick<Configuration, 'auto' | 'challenge' | 'display' | 'language' | 'name' | 'type' | 'workers'>
	> {
		/** JSON string of additional configuration options */
		configuration?: string;
		/** ID of the `<form>` to associate with (like the native `form` attribute) */
		form?: string;
		/** Unique identifier for this widget instance */
		id?: string;
		/** Theme identifier */
		theme?: string;
	}

	// -----------------------------------------------------------------------------
	// Constants
	// -----------------------------------------------------------------------------

	/** Selector for text inputs/textareas that should be included in server verification */
	const fieldsSelector =
		'input[type="text"]:not([data-no-spamfilter]), textarea:not([data-no-spamfilter])';

	/** Selector for form submit buttons (used as floating anchor fallback) */
	const submitButtonSelector =
		'input[type="submit"], button[type="submit"], button:not([type="button"]):not([type="reset"])';

	/** Languages that use right-to-left text direction */
	const rtlLanguages = ['ar', 'fa', 'he', 'ur'];

	/** Whether the page is the secure context (HTTPS, required for crypto APIs) */
	const { isSecureContext } = globalThis;

	/** Global ALTCHA default configuration store */
	const { store: altchaDefaults } = globalThis.$altcha.defaults;

	/** Number of logical CPU cores (fallback: 2) */
	const hardwareConcurrency = navigator.hardwareConcurrency || 2;

	/** Device memory in GB (non-standard, 0 if unavailable) */
	// @ts-expect-error non-standard property
	const deviceMemory = navigator.deviceMemory || 0;

	/** Default worker count — capped at 4 on low-memory devices */
	const defaultWorkers =
		deviceMemory && deviceMemory <= 4 ? Math.min(4, hardwareConcurrency) : hardwareConcurrency;

	/** Global i18n string store */
	const altchaI18nStore = globalThis.$altcha.i18n.store;

	/** Reference to this custom element's host node */
	const instance = $host<HTMLElement & WidgetMethods>();

	/**
	 * Dispatch a CustomEvent from the host element on the next tick.
	 * Using tick() ensures Svelte has finished updating the DOM first.
	 */
	const dispatch = <T,>(event: string, detail?: T) => {
		tick().then(() => {
			instance?.dispatchEvent(
				new CustomEvent(event, {
					detail
				})
			);
		});
	};

	// -----------------------------------------------------------------------------
	// Reactive State
	// -----------------------------------------------------------------------------

	let { ...props }: Props = $props();
	let hisCollector: Collector | null = null;

	let baseUrl = $state<URL>(new URL(location.origin));
	let checked = $state(false);
	let codeChallenge = $state<CodeChallenge | null>(null);
	let currentController = $state<AbortController | null>(null);
	let currentDisplay = $state<Configuration['display'] | null>(null);
	let currentState = $state<State>(State.UNVERIFIED);
	let elAnchorArrow = $state<HTMLElement | null>();
	let elFloatingAnchor = $state<HTMLElement | null>();
	let elForm = $state<HTMLFormElement | null>(null);
	let elRoot = $state<HTMLElement>();
	let elSubmitter = $state<HTMLElement | null>(null);
	let error = $state<string | null>(null);
	let expirationTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
	let payload = $state<string | null>(null);
	let plugins = $state<BasePlugin[]>([]);
	let updateUISignal = $state<number>(0);
	/** User/runtime overrides merged on top of defaults */
	let userConfig = $state<Partial<Configuration>>({});
	/** Whether the widget is visible (relevant for floating/bar/overlay modes) */
	let visible = $state(true);

	// -----------------------------------------------------------------------------
	// Derived State
	// -----------------------------------------------------------------------------

	/** Merged configuration: defaults <- global store <- user overrides */
	const config = $derived<Configuration>({
		fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
		audioChallengeLanguage: '',
		auto: 'off',
		barPlacement: 'bottom',
		challenge: '',
		codeChallenge: null,
		codeChallengeDisplay: 'standard',
		credentials: null,
		debug: false,
		disableAutoFocus: false,
		display: 'standard',
		floatingAnchor: '',
		floatingOffset: 8,
		floatingPersist: false,
		floatingPlacement: 'auto',
		hideFooter: false,
		hideLogo: false,
		humanInteractionSignature: true,
		language: '',
		mockError: false,
		minDuration: 500,
		overlayContent: '',
		name: 'altcha',
		popoverPlacement: 'auto',
		retryOnOutOfMemoryError: true,
		setCookie: null,
		serverVerificationFields: false,
		serverVerificationTimeZone: false,
		test: false,
		timeout: 90_000,
		type: 'checkbox',
		validationMessage: '',
		verifyFunction: null,
		verifyUrl: '',
		workers: defaultWorkers,
		...$altchaDefaults,
		...userConfig
	});

	/** Unique ID for the checkbox input element */
	const checkboxId = $derived(
		`altcha-checkbox-${props.id || Math.floor(Math.random() * 1e12).toString(16)}`
	);

	/** Resolves the correct checkbox component based on config.type */
	const CheckboxComponent = $derived(getCheckboxComponent(config.type));

	const auto = $derived(config.auto);
	const loading = $derived(currentState === State.VERIFYING);
	const showFooter = $derived(!config.hideFooter);
	const showLogo = $derived(!config.hideLogo && config.display !== 'bar');

	/** Resolved i18n locale and string map based on language preferences */
	const locale = $derived(
		getI18nStrings($altchaI18nStore, [
			config.language,
			document.documentElement.lang,
			...navigator.languages
		])
	);

	/** Text direction — 'rtl' for Arabic, Hebrew, etc. */
	const dir = $derived(rtlLanguages.includes(locale.language) ? 'rtl' : undefined);

	const strings = $derived({
		...locale.strings
	});

	/** Absolute URL for the code challenge audio (if provided as a relative path) */
	const codeChallengeAudioUrl = $derived(
		codeChallenge?.audio?.match(/^(https?:)?\//)
			? getUrl(codeChallenge.audio, baseUrl, {
					language: config.audioChallengeLanguage || locale.language
				}).toString()
			: codeChallenge?.audio
	);

	/** Absolute URL for the code challenge image (if provided as a relative path) */
	const codeChallengeImageUrl = $derived(
		codeChallenge?.image?.match(/^(https?:)?\//)
			? getUrl(codeChallenge.image, baseUrl)
			: codeChallenge?.image
	);

	// -----------------------------------------------------------------------------
	// Effects
	// -----------------------------------------------------------------------------

	/** Sync HTML attribute props into the configuration */
	$effect(() => {
		configure({
			auto: props.auto,
			challenge: props.challenge,
			display: props.display,
			language: props.language,
			name: props.name,
			type: props.type,
			workers: props.workers
		});
	});

	/**
	 * Explicitly reflect `theme` onto the host attribute.
	 * `theme` is a pure CSS hook (`altcha-widget[theme='...']`) not consumed by this
	 * component, so it's never part of its own $props()/exports — the generic
	 * customElement `reflect: true` only reflects props the component actually exposes,
	 * so it silently no-ops here. Frameworks that set `theme` as a JS property instead of
	 * an HTML attribute (e.g. client-side rendering) would otherwise leave the attribute
	 * missing until a full page reload re-parses static HTML.
	 */
	$effect(() => {
		if (props.theme) {
			instance?.setAttribute('theme', props.theme);
		} else {
			instance?.removeAttribute('theme');
		}
	});

	/** Parse and apply the JSON `configuration` attribute */
	$effect(() => {
		if (props.configuration) {
			try {
				configure(JSON.parse(props.configuration));
			} catch {
				log('unable to parse the `configuration` attribute (JSON expected)');
			}
		}
	});

	/** Update visibility whenever display mode changes */
	$effect(() => {
		if (currentDisplay !== config.display) {
			setDisplay(config.display);
		}
	});

	/** Uncheck the checkbox while verification is in progress */
	$effect(() => {
		if (checked && currentState === State.VERIFYING) {
			checked = false;
		}
	});

	/** Re-check the checkbox once verification succeeds */
	$effect(() => {
		if (!checked && currentState === State.VERIFIED) {
			checked = true;
		}
	});

	/** Keep the native checkbox element in sync with the `checked` state */
	$effect(() => {
		if (!checked) {
			const checkbox = getCheckboxElement();
			if (checkbox && checkbox.checked) {
				checkbox.checked = false;
			}
		}
	});

	/** Clear any validation error once verified */
	$effect(() => {
		if (currentState === State.VERIFIED) {
			getCheckboxElement()?.setCustomValidity('');
		}
	});

	/** Auto-verify on page load if configured */
	$effect(() => {
		if (auto === 'onload') {
			const tm = setTimeout(() => {
				verify();
			}, 10);
			return () => {
				if (tm) {
					clearTimeout(tm);
				}
			};
		}
	});

	/** Log errors to the console when they occur */
	$effect(() => {
		if (error) {
			log('error:', error);
		}
	});

	/** Sets the payload cookie if enabled */
	$effect(() => {
		if (payload && config.setCookie) {
			setCookie(payload, config.setCookie);
		}
	});

	// -----------------------------------------------------------------------------
	// Lifecycle
	// -----------------------------------------------------------------------------

	onMount(() => {
		log('mounted', ALTCHA_VERSION);

		// Register this instance in the global set
		if (instance) {
			globalThis.$altcha.instances.add(instance);
		}

		// Find and attach to the associated form: the `form` attribute (resolved like the
		// native attribute) takes precedence over the closest ancestor form
		elForm = resolveForm();
		elForm?.addEventListener('reset', onFormReset);
		elForm?.addEventListener('submit', onFormSubmit, {
			capture: true
		});
		// Listen on the document so focus on fields associated via `form="…"` is caught too
		document.addEventListener('focusin', onFormFocusIn);

		activatePlugins();

		if (config.humanInteractionSignature) {
			log('human interaction signature enabled');
			hisCollector = new Collector();
		}

		dispatch('load');

		if (!isSecureContext) {
			log('secure context (HTTPS) required');
		}

		// Cleanup on unmount
		return () => {
			destroyPlugins();
			if (instance) {
				globalThis.$altcha.instances.delete(instance);
			}
			if (expirationTimeout) {
				clearTimeout(expirationTimeout);
			}
			elForm?.removeEventListener('reset', onFormReset);
			elForm?.removeEventListener('submit', onFormSubmit, {
				capture: true
			});
			document.removeEventListener('focusin', onFormFocusIn);
			hisCollector?.destroy();
		};
	});

	// -----------------------------------------------------------------------------
	// Plugin System
	// -----------------------------------------------------------------------------

	/** Instantiate and activate all registered global plugins */
	function activatePlugins() {
		plugins = [...globalThis.$altcha.plugins].map((PluginCls) => new PluginCls(instance));
		log(
			'activating plugins',
			plugins.map((plugin) => plugin.constructor.name)
		);
		for (const plugin of plugins) {
			plugin.activate();
		}
	}

	/**
	 * Call a named hook on every active plugin in order.
	 * Returns the result from the last plugin that provides one.
	 */
	async function callHook<
		K extends 'onFetchChallenge' | 'onRequestServerVerification' | 'onVerify',
		R extends Awaited<ReturnType<(typeof BasePlugin.prototype)[K]>>
	>(hook: K, ...args: any[]): Promise<R | undefined> {
		let result: R | undefined = undefined;
		for (const plugin of plugins) {
			// @ts-expect-error mixing types
			result = await plugin[hook].call(plugin, ...args);
		}
		return result;
	}

	/** Tear down all active plugins */
	function destroyPlugins() {
		for (const plugin of plugins) {
			plugin.destroy();
		}
	}

	// -----------------------------------------------------------------------------
	// Challenge Handling
	// -----------------------------------------------------------------------------

	/**
	 * Convert a legacy v1 challenge format into the current Challenge shape.
	 * Extracts query parameters from the salt and maps fields accordingly.
	 */
	function createChallengeFromV1(challenge: ChallengeV1): Challenge {
		const [_, queryParams] = challenge.salt.split('?');
		const data: Record<string, string> = {};
		if (queryParams) {
			try {
				Object.assign(data, Object.fromEntries(new URLSearchParams(queryParams).entries()));
			} catch {}
		}
		const result = {
			codeChallenge: challenge.codeChallenge,
			parameters: {
				algorithm: challenge.algorithm,
				cost: 1,
				data,
				expiresAt: data?.expires ? parseInt(data.expires, 10) : undefined,
				keyLength:
					challenge.algorithm === 'SHA-512' ? 64 : challenge.algorithm === 'SHA-384' ? 48 : 32,
				nonce: bufferToHex(new TextEncoder().encode(challenge.salt)),
				keyPrefix: challenge.challenge,
				salt: ''
			} satisfies ChallengeParameters,
			signature: challenge.signature
		} satisfies Challenge;

		// Attach hidden metadata for payload creation and version detection
		Object.defineProperties(result, {
			_originalSalt: {
				enumerable: false,
				value: challenge.salt,
				writable: false
			},
			_version: {
				enumerable: false,
				value: 1,
				writable: false
			}
		});
		return result;
	}

	/**
	 * Build the v1 payload format from a challenge and its solution.
	 * Used when the challenge was originally in v1 format.
	 */
	function createPayloadV1(challenge: Challenge, solution: Solution): PayloadV1 {
		return {
			algorithm: challenge.parameters.algorithm,
			challenge: challenge.parameters.keyPrefix,
			number: solution.counter,
			salt:
				'_originalSalt' in challenge
					? (challenge._originalSalt as string)
					: challenge.parameters.nonce,
			signature: challenge.signature,
			took: solution.time || 0
		};
	}

	/** Simple promise-based delay utility */
	async function delay(ms: number) {
		await new Promise((resolve) => setTimeout(resolve, ms));
	}

	/**
	 * Fetch or parse a challenge from the configured source.
	 * Supports: URL string (fetched via HTTP), JSON string (parsed), or object (cloned).
	 * Plugins can intercept via the `onFetchChallenge` hook.
	 */
	async function fetchChallenge(
		source: string | Challenge | null = config.challenge,
		requestOptions?: RequestInit
	): Promise<Challenge | null> {
		// Let plugins handle the fetch if they want
		const hook = await callHook('onFetchChallenge', source);
		let challenge = null;

		if (hook !== undefined) {
			return hook;
		}

		if (typeof source === 'string') {
			if (source.startsWith('{')) {
				// Source is a JSON string — parse directly
				log('parsing JSON challenge');
				try {
					challenge = JSON.parse(source);
				} catch {
					throw new Error(`Unable to parse JSON challenge.`);
				}
			} else {
				// Source is a URL — fetch from server
				log('fetching challenge from', requestOptions?.method || 'GET', source);
				baseUrl = new URL(source, location.origin);
				const resp = await config.fetch(source, {
					credentials: config.credentials || undefined,
					...requestOptions
				});
				await validateResponse(resp);

				// Server may send extra config via response header
				const configHeader = resp.headers.get('x-altcha-config');
				if (configHeader) {
					processConfigHeader(configHeader);
				}
				const json = await resp.json();
				if (json && 'his' in json && json.his) {
					log('requested HIS');
					if (!hisCollector) {
						throw new Error('Server requested HIS data but collector is disabled.');
					}
					return fetchChallenge(getUrl(json.his.url, baseUrl), {
						body: JSON.stringify({
							his: hisCollector.export()
						}),
						headers: {
							'content-type': 'application/json'
						},
						method: 'POST'
					});
				}

				if (json && 'hisResult' in json && json.hisResult) {
					log('HIS result', json.hisResult);
				}

				challenge = json;
			}
		} else if (source && typeof source === 'object') {
			// Source is an object — deep clone to avoid mutation
			try {
				challenge = JSON.parse(JSON.stringify(source));
			} catch {
				throw new Error(`Unable to parse JSON challenge.`);
			}
		}

		// Handle legacy v1 challenge format
		if (isChallengeV1(challenge)) {
			challenge = createChallengeFromV1(challenge as ChallengeV1);
		}

		if (!isChallengeValid(challenge)) {
			throw new Error(`Challenge validation failed.`);
		}

		return challenge;
	}

	/** Checks whether the challenge is V1 */
	function isChallengeV1(challenge: any) {
		return challenge !== null && typeof challenge === 'object' && 'challenge' in challenge;
	}

	/** Validate that a challenge object has all required fields */
	function isChallengeValid(challenge: any) {
		return (
			!!challenge &&
			typeof challenge === 'object' &&
			'parameters' in challenge &&
			!!challenge.parameters &&
			typeof challenge.parameters === 'object' &&
			'algorithm' in challenge.parameters &&
			'nonce' in challenge.parameters &&
			'salt' in challenge.parameters &&
			'keyPrefix' in challenge.parameters
		);
	}

	// -----------------------------------------------------------------------------
	// DOM Helpers
	// -----------------------------------------------------------------------------

	/** Get the hidden checkbox input element by its generated ID */
	function getCheckboxElement() {
		return document.getElementById(checkboxId) as HTMLInputElement | null;
	}

	/** Map config.type to the appropriate Svelte checkbox component */
	function getCheckboxComponent(type: string) {
		switch (type) {
			case 'checkbox':
				return Checkbox;
			case 'switch':
				return Switch;
			case 'native':
			default:
				return CheckboxNative;
		}
	}

	/**
	 * Resolve i18n strings by matching the best available language.
	 */
	function getI18nStrings(
		i18n: Record<string, any>,
		languages: string[]
	): {
		language: string;
		strings: Strings;
	} {
		const codes = Object.keys(i18n).map((code) => code.toLowerCase());
		let language = languages.reduce(
			(acc, lang) => {
				lang = lang.toLowerCase();
				return (
					acc ||
					(i18n[lang] ? lang : null) ||
					codes.find((code) => lang.split('-')[0] === code.split('-')[0]) ||
					null
				);
			},
			null as string | null
		);
		if (!i18n[language || '']) {
			language = 'en';
		}
		return {
			language: language!,
			strings: i18n[language!]
		};
	}

	/** Map display mode to its placement value */
	function getPlacement(display: Configuration['display']) {
		switch (display) {
			case 'bar':
				return config.barPlacement || 'bottom';
			case 'floating':
				return config.floatingPlacement || 'auto';
			default:
				return undefined;
		}
	}

	/**
	 * Resolve the associated form. Mirrors native `form` attribute semantics:
	 * when set, only a `<form>` with that ID in the same tree root qualifies.
	 */
	function resolveForm(): HTMLFormElement | null {
		if (props.form) {
			const root = instance?.getRootNode() as Document | ShadowRoot | undefined;
			const el = root?.getElementById?.(props.form);
			return el instanceof HTMLFormElement ? el : null;
		}
		return elRoot?.closest('form') ?? null;
	}

	/**
	 * Return the form's controls matching `selector`, including those associated
	 * via the `form` attribute outside the form's subtree.
	 */
	function getFormElements<T extends Element = HTMLElement>(selector: string): T[] {
		return elForm ? (Array.from(elForm.elements).filter((el) => el.matches(selector)) as T[]) : [];
	}

	/**
	 * Collect text field values from the associated form.
	 * Used for server-side spam verification. Normalizes line endings to CRLF.
	 */
	function getTextFields(names?: string[]) {
		const elInputs = getFormElements<HTMLInputElement>(
			names?.length ? names.map((name) => `input[name="${name}"]`).join(', ') : fieldsSelector
		);
		return elInputs.reduce(
			(acc, el) => {
				const name = el.name;
				const value = el.value;
				if (name && value) {
					acc[name] = /\n/.test(value) ? value.replace(/(?<!\r)\n/g, '\r\n') : value;
				}
				return acc;
			},
			{} as Record<string, string>
		);
	}

	/** Get the user's timezone (e.g. "America/New_York") for server verification */
	function getTimeZone(): string | undefined {
		try {
			return Intl.DateTimeFormat().resolvedOptions().timeZone;
		} catch {}
		return undefined;
	}

	/**
	 * Build an absolute URL from a relative URI, inheriting search params from baseUrl.
	 * Optionally appends additional query parameters.
	 */
	function getUrl(uri: string, baseUrl: URL, params?: Record<string, string | undefined | null>) {
		const result = new URL(uri, baseUrl);
		if (!result.search) {
			result.search = baseUrl.search;
		}
		if (params) {
			for (const key in params) {
				if (params[key] !== undefined && params[key] !== null) {
					result.searchParams.set(key, params[key]);
				}
			}
		}
		return result.toString();
	}

	// -----------------------------------------------------------------------------
	// Event Handlers
	// -----------------------------------------------------------------------------

	/** Handle checkbox click — triggers verification or reset */
	function onCheckboxChange(
		ev: Event & {
			currentTarget: EventTarget & HTMLInputElement;
		}
	) {
		if (!checked && ev.currentTarget.checked) {
			ev.preventDefault();
			ev.currentTarget.checked = false;
			if (currentState !== State.VERIFYING) {
				verify();
			}
		} else if (!ev.currentTarget.checked) {
			ev.preventDefault();
			reset();
		}
	}

	/** Set custom validation message when checkbox is invalid */
	function onCheckboxInvalid(
		ev: Event & {
			currentTarget: EventTarget & HTMLInputElement;
		}
	) {
		if (currentState === State.VERIFYING) {
			ev.currentTarget.setCustomValidity(strings.waitAlert);
		} else if (config.validationMessage) {
			ev.currentTarget.setCustomValidity(config.validationMessage);
		}
	}

	/** Handle close button click — abort verification and reset */
	function onCloseClick() {
		setDisplay(config.display);
		reset();
	}

	/** Reposition floating widget on scroll */
	function onDocumentScroll() {
		updateUI();
	}

	/** Close floating widget when clicking outside of it */
	function onDocumentClick(ev: MouseEvent) {
		const target = ev.target as HTMLElement;
		if (
			config.display === 'floating' &&
			target &&
			!instance?.contains(target) &&
			!target.hasAttribute('data-backdrop') &&
			!target.closest('[data-popover]') &&
			currentState !== State.VERIFIED &&
			!config.floatingPersist
		) {
			hide();
		}
	}

	/** Auto-verify when any field of the associated form receives focus (if auto='onfocus') */
	function onFormFocusIn(ev: FocusEvent) {
		const target = ev.target;
		if (
			auto === 'onfocus' &&
			currentState === State.UNVERIFIED &&
			elForm &&
			target instanceof Element &&
			(elForm.contains(target) || (target as HTMLInputElement).form === elForm)
		) {
			verify();
		}
	}

	/** Reset widget when the parent form is reset */
	function onFormReset() {
		setDisplay(config.display);
		reset();
	}

	/**
	 * Intercept form submission when auto='onsubmit'.
	 * Prevents submission, runs verification, then re-submits on success.
	 */
	function onFormSubmit(ev: SubmitEvent) {
		const target = ev.target as HTMLFormElement | null;
		if (target?.getAttribute('data-code-challenge') === 'true') {
			// code challenge form submission
			return;
		}
		if (auto === 'onsubmit' && currentState === State.UNVERIFIED) {
			ev.preventDefault();
			ev.stopPropagation();
			elSubmitter = ev.submitter as HTMLElement | null;
			show();
			verify().then((result) => {
				if (result && !codeChallenge) {
					tick().then(() => {
						requestSubmit(elSubmitter);
					});
				}
			});
		}
	}

	/** Reset state when restoring the page from the bfcache */
	function onWindowPageshow(ev: PageTransitionEvent) {
		if (ev.persisted) {
			setDisplay(config.display);
			reset();
		}
	}

	/** Reposition floating widget on window resize */
	function onWindowResize() {
		updateUI();
	}

	// -----------------------------------------------------------------------------
	// Configuration & Server Communication
	// -----------------------------------------------------------------------------

	/** Apply configuration received in the `x-altcha-config` response header */
	function processConfigHeader(jsonConfig: string) {
		try {
			const json = JSON.parse(jsonConfig);
			if (json && typeof json === 'object') {
				configure({
					// Backward compatibility mappings
					serverVerificationFields: json?.sentinel?.fields,
					serverVerificationTimeZone: json?.sentinel?.timeZone,
					verifyUrl: json.verifyurl,
					...json
				});
			}
		} catch (err) {
			log('unable to configure from x-altcha-config header', err);
		}
	}

	/**
	 * Position the widget relative to its anchor element in floating mode.
	 * Determines whether to show above or below based on available viewport space.
	 */
	function repositionFloating(viewportOffset: number = 20) {
		if (!elRoot) {
			return;
		}
		const floatingPlacement = config.floatingPlacement;

		// Resolve the anchor element (submit button, custom selector, or form)
		if (!elFloatingAnchor) {
			elFloatingAnchor =
				(config.floatingAnchor instanceof HTMLElement
					? config.floatingAnchor
					: config.floatingAnchor
						? document.querySelector<HTMLElement>(config.floatingAnchor)
						: getFormElements(submitButtonSelector)[0]) || elForm;
			if (!elFloatingAnchor) {
				log('unable to find floating anchor element');
				return;
			}
		}

		const offsetY = parseInt(config.floatingOffset as any, 10) || 12;
		const anchorBoundry = elFloatingAnchor.getBoundingClientRect();
		const elBoundary = elRoot.getBoundingClientRect();
		const docHeight = document.documentElement.clientHeight;
		const docWidth = document.documentElement.clientWidth;

		// Auto-detect placement: show on top if not enough room below
		const showOnTop =
			!floatingPlacement || floatingPlacement === 'auto'
				? anchorBoundry.bottom + elBoundary.height + offsetY + viewportOffset > docHeight
				: floatingPlacement === 'top';

		// Center horizontally on the anchor, clamped to viewport edges
		const left = Math.max(
			viewportOffset,
			Math.min(
				docWidth - viewportOffset - elBoundary.width,
				anchorBoundry.left + anchorBoundry.width / 2 - elBoundary.width / 2
			)
		);

		elRoot.style.setProperty('--altcha-floating-left', `${left}px`);
		elRoot.style.setProperty(
			'--altcha-floating-top',
			showOnTop
				? `${anchorBoundry.top - (elBoundary.height + offsetY)}px`
				: `${anchorBoundry.bottom + offsetY}px`
		);
		elRoot.setAttribute('data-floating-position', showOnTop ? 'top' : 'bottom');

		// Position the arrow indicator
		if (elAnchorArrow) {
			const anchorArrowBoundry = elAnchorArrow.getBoundingClientRect();
			elAnchorArrow.style.left =
				anchorBoundry.left - left + anchorBoundry.width / 2 - anchorArrowBoundry.width / 2 + 'px';
		}
	}

	/**
	 * Send the PoW payload (and optional code) to the server for verification.
	 * Plugins can intercept via `onRequestServerVerification`.
	 */
	async function requestServerVerification(
		verificationPayload: string,
		code?: string
	): Promise<ServerVerificationResult | null> {
		const hook = await callHook('onRequestServerVerification', verificationPayload, code);
		if (hook !== undefined) {
			return hook;
		}
		log('requesting server verification from', config.verifyUrl);
		if (!config.verifyUrl) {
			throw new Error(`Parameter verifyUrl must be set for server verification.`);
		}
		const resp = await config.fetch(getUrl(config.verifyUrl, baseUrl), {
			body: JSON.stringify({
				code,
				fields: config.serverVerificationFields ? getTextFields() : undefined,
				payload: verificationPayload,
				timeZone: config.serverVerificationTimeZone ? getTimeZone() : undefined
			}),
			credentials: config.credentials || undefined,
			headers: {
				'Content-Type': 'application/json'
			},
			method: 'POST'
		});
		await validateResponse(resp);
		const json = await resp.json();
		if (json && typeof json === 'object' && 'payload' in json && !!json.payload) {
			dispatch('serververification', json);
		}
		return json;
	}

	/**
	 * Submit the parent form programmatically.
	 * Falls back to click/submit for browsers without requestSubmit (e.g. iOS 16).
	 */
	function requestSubmit(submitter?: HTMLElement | null) {
		if (elForm && 'requestSubmit' in elForm) {
			elForm.requestSubmit(submitter);
			// @ts-ignore
		} else if (elForm?.reportValidity()) {
			if (submitter) {
				submitter.click();
			} else {
				// @ts-ignore
				elForm.submit();
			}
		}
	}

	/**
	 * Set a cookie with specified value and options.
	 */
	function setCookie(value: string, options: SetCookieOptions = {}) {
		const { domain, name = config.name, maxAge, path, sameSite, secure } = options;
		let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
		if (domain) cookie += `; Domain=${domain}`;
		if (maxAge != null) cookie += `; Max-Age=${maxAge}`;
		if (path) cookie += `; Path=${path}`;
		if (sameSite) cookie += `; SameSite=${sameSite}`;
		if (secure) cookie += `; Secure`;
		document.cookie = cookie;
	}

	/**
	 * Apply display mode — hides widget for bar/floating/overlay modes
	 * and defaults to auto='onsubmit' if no auto mode is set.
	 */
	function setDisplay(value: Configuration['display']) {
		switch (value) {
			case 'bar':
			case 'floating':
			case 'overlay':
				hide();
				if (!auto || auto === 'off') {
					userConfig.auto = 'onsubmit';
				}
				break;
			case 'standard':
				show();
			default:
		}
		if (currentDisplay !== value) {
			currentDisplay = value;
		}
	}

	/**
	 * Set a timeout to expire the challenge at the given Unix timestamp.
	 * Transitions to EXPIRED state and dispatches an 'expired' event.
	 */
	function setChallengeExpiration(expiresAt: number) {
		if (expirationTimeout) {
			clearTimeout(expirationTimeout);
		}
		const onExpired = () => {
			if (currentState !== State.UNVERIFIED) {
				checked = false;
				setState(State.EXPIRED);
			} else {
				reset();
			}
			dispatch('expired');
		};
		const duration = expiresAt * 1_000 - Date.now();
		if (duration >= 1) {
			expirationTimeout = setTimeout(onExpired, duration);
		} else {
			onExpired();
		}
	}

	/** Validate that a server response is OK and returns JSON */
	async function validateResponse(resp: Response) {
		if (resp.status >= 400) {
			if (resp.headers.get('content-type')?.includes('/json')) {
				let json;
				try {
					json = await resp.json();
				} catch {}
				if (json && 'error' in json) {
					throw new Error(`Server responded with ${resp.status} - ${json.error}`);
				}
			}
			throw new Error(`Server responded with ${resp.status}.`);
		}
		const contentType = resp.headers.get('content-type');
		if (!contentType || !contentType.includes('/json')) {
			throw new Error(
				`Server responded with invalid content-type. Expected application/json, received ${contentType}.`
			);
		}
	}

	/**
	 * Verify a code challenge by sending it to the server.
	 * On success, transitions to VERIFIED and auto-submits if configured.
	 */
	async function verifyServer(code?: string) {
		if (!payload) {
			setState(State.ERROR, 'Cannot verify code challenge without PoW payload.');
			return;
		}
		setState(State.VERIFYING);
		let result: ServerVerificationResult | null = null;
		if (config.verifyUrl) {
			result = await requestServerVerification(payload, code);
		} else if (config.verifyFunction) {
			result = await config.verifyFunction(payload, code);
		} else {
			setState(State.ERROR, 'Parameter verifyUrl is required for code challenge verification.');
			return;
		}
		if (result?.payload) {
			payload = result.payload;
			log('server payload', payload);
		}
		if (result?.verified === true) {
			log('verified');
			setState(State.VERIFIED);
			dispatch('verified', { payload });
			if (auto === 'onsubmit') {
				tick().then(() => {
					requestSubmit(elSubmitter);
				});
			}
		} else {
			setState(State.ERROR, result?.reason || 'Verification failed.');
		}
		if (!config.disableAutoFocus) {
			getCheckboxElement()?.focus();
		}
	}

	// -----------------------------------------------------------------------------
	// Public API — Methods exposed on the custom element
	// -----------------------------------------------------------------------------

	/** Merge new configuration options into the current config */
	export function configure(newConfig: Partial<Configuration>) {
		Object.assign(userConfig, {
			...Object.fromEntries(Object.entries(newConfig).filter(([_, value]) => value !== undefined))
		});
	}

	/** Get a snapshot of the current merged configuration */
	export function getConfiguration() {
		return {
			...config
		};
	}

	/** Get the current widget state (UNVERIFIED, VERIFYING, VERIFIED, etc.) */
	export function getState() {
		return currentState;
	}

	/** Hide the widget (used in floating/bar/overlay modes) */
	export function hide() {
		visible = false;
	}

	/** Log messages to the console (only when debug mode is enabled, or for errors) */
	export function log(...args: unknown[]) {
		if (config.debug || args.some((a) => a instanceof Error)) {
			console[args[0] instanceof Error ? 'error' : 'log'](
				'ALTCHA',
				`[name=${config.name}]`,
				...args
			);
		}
	}

	/** Reset the widget to its initial unverified state */
	export function reset(newState: State = State.UNVERIFIED, err: string | null = null) {
		checked = false;
		error = err;
		payload = null;
		if (currentController) {
			currentController.abort();
		}
		if (expirationTimeout) {
			clearTimeout(expirationTimeout);
			expirationTimeout = null;
		}
		setState(newState);
	}

	/** Update the widget state and dispatch a 'statechange' event */
	export function setState(newState: State, err: string | null = null) {
		currentState = newState;
		error = err;
		dispatch('statechange', {
			payload,
			state: currentState
		});
	}

	/** Show the widget and update its position */
	export function show() {
		visible = true;
		tick().then(() => {
			updateUI();
		});
	}

	/** Update UI positioning */
	export function updateUI() {
		switch (config.display) {
			case 'floating':
				return repositionFloating();
			default:
			// noop
		}
		updateUISignal += 1;
	}

	// -----------------------------------------------------------------------------
	// Main Verification Flow
	// -----------------------------------------------------------------------------

	/**
	 * Run the full verification process:
	 * 1. Fetch or parse the cryptographic challenge
	 * 2. Solve the proof-of-work using web workers
	 * 3. Encode the solution as a base64 JSON payload
	 * 4. Optionally verify with the server or present a code challenge
	 *
	 * Returns the challenge, payload, and solution on success, or null on failure.
	 */
	export async function verify(options: VerifyOptions = {}): Promise<VerifyResult | null> {
		const {
			concurrency = Math.max(1, config.workers),
			controller = new AbortController(),
			minDuration = config.minDuration
		} = options;
		const start = performance.now();
		let challenge: Challenge | null = null;
		let solution: Solution | null = null;
		let isChallengeV1 = false;

		// Allow plugins to override the entire verification flow
		const hook = await callHook('onVerify', options);
		if (hook !== undefined) {
			return hook;
		}

		reset(State.VERIFYING);
		currentController = controller;

		try {
			if (!isSecureContext) {
				throw new Error('Secure context (HTTPS) required.');
			}
			if (config.mockError) {
				throw new Error('Mock error.');
			}

			// Test mode — skip PoW, return a dummy payload
			if (config.test) {
				log('running test mode with null challenge');
				await delay(Math.max(0, minDuration - (performance.now() - start)));
				if (currentController?.signal.aborted) {
					reset();
					return null;
				}
				payload = btoa(
					JSON.stringify({
						challenge: null,
						solution: null,
						test: true
					})
				);
				log('verified');
				setState(State.VERIFIED);
				dispatch('verified', { payload });
				return {
					payload
				};
			}

			// Step 1: Fetch the challenge
			challenge = await fetchChallenge();
			if (!challenge) {
				throw new Error('Failed to fetch challenge.');
			}
			log('challenge', challenge);

			// Additional configuration options from the challenge
			if ('configuration' in challenge) {
				log('re-configuring from challenge', challenge.configuration);
				configure(challenge.configuration as Partial<Configuration>);
			}

			// Start expiration timer if the challenge has a TTL
			if (challenge.parameters.expiresAt) {
				setChallengeExpiration(challenge.parameters.expiresAt);
			}

			isChallengeV1 = '_version' in challenge && challenge._version === 1;

			// Step 2: Get the worker factory for the specified algorithm
			const createWorker = globalThis.$altcha.algorithms.get(challenge.parameters.algorithm);
			if (!createWorker) {
				throw new Error(`Unsupported algorithm ${challenge.parameters.algorithm}.`);
			}

			// Step 3: Solve the PoW challenge using web workers
			solution = await solveChallengeWorkers({
				challenge,
				concurrency,
				controller,
				createWorker,
				counterMode: isChallengeV1 ? 'string' : 'uint32',
				onOutOfMemory: (c) => {
					log('out of memory error received');
					dispatch('outofmemory');
					// Retry with fewer workers if configured
					if (config.retryOnOutOfMemoryError && c > 1) {
						const retryConcurrency = Math.floor(c / 2);
						log(`retrying with ${retryConcurrency} workers...`);
						return retryConcurrency;
					}
				},
				timeout: config.timeout
			});

			// Check if verification was aborted
			if (currentController?.signal.aborted) {
				reset();
				return null;
			}

			if (!solution) {
				throw new Error('Failed to find solution.');
			}
			log('solution', solution);

			// Ensure minimum visual feedback time
			await delay(Math.max(0, minDuration - (performance.now() - start)));

			// Step 4: Encode the payload
			codeChallenge = challenge.codeChallenge || config.codeChallenge || null;
			if (isChallengeV1) {
				payload = btoa(JSON.stringify(createPayloadV1(challenge, solution)));
			} else {
				payload = btoa(
					JSON.stringify({
						challenge: {
							parameters: challenge.parameters,
							signature: challenge.signature
						},
						solution
					})
				);
			}

			// Step 5: Finalize — code challenge, server verify, or done
			if (codeChallenge) {
				log('requesting code verification');
				setState(State.CODE);
				dispatch('codechallenge', { codeChallenge });
			} else if (config.verifyUrl) {
				await verifyServer();
			} else {
				log('verified');
				setState(State.VERIFIED);
				dispatch('verified', { payload });
			}
		} catch (err) {
			log('verification failed', err);
			setState(State.ERROR, String(err));
			return null;
		} finally {
			currentController = null;
		}

		return {
			challenge,
			payload,
			solution
		};
	}
</script>

<svelte:document onscroll={onDocumentScroll} onclick={onDocumentClick} />
<svelte:window onpageshow={onWindowPageshow} onresize={onWindowResize} />

{#if config.display === 'overlay' && visible}
	<div class="altcha-overlay-backdrop" data-backdrop></div>
{/if}

<div
	bind:this={elRoot}
	class="altcha"
	data-state={currentState}
	data-display={config.display || undefined}
	data-placement={getPlacement(config.display)}
	data-visible={visible || undefined}
	{dir}
>
	{#if config.display === 'overlay' && visible}
		<!-- svelte-ignore a11y_interactive_supports_focus -->
		<!-- svelte-ignore a11y_click_events_have_key_events -->
		<div role="button" class="altcha-overlay-close" onclickcapture={onCloseClick}>&times;</div>

		{#if config.overlayContent}
			<div class="altcha-overlay-content">
				{@html document.querySelector(config.overlayContent)?.innerHTML}
			</div>
		{/if}
	{/if}

	<div class="altcha-main">
		<div>
			<div class="altcha-checkbox-wrap">
				<CheckboxComponent
					id={checkboxId}
					form={props.form}
					name=""
					required={(config.display === 'standard' && auto !== 'onsubmit') ||
						currentState === State.VERIFYING}
					{loading}
					{checked}
					onchange={onCheckboxChange}
					oninvalid={onCheckboxInvalid}
				/>
				<label for={checkboxId}>
					{#if currentState === State.CODE && codeChallenge}
						{strings.verificationRequired}
					{:else if currentState === State.VERIFYING}
						{strings.verifying}
					{:else if currentState === State.VERIFIED}
						{strings.verified}
					{:else}
						{strings.label}
					{/if}
				</label>
			</div>

			{#if showLogo}
				<Logo {strings} />
			{/if}
		</div>

		{#if showFooter}
			<Footer logo={config.display === 'bar' && showLogo} {strings} />
		{/if}

		{#if config.display === 'floating'}
			<div bind:this={elAnchorArrow} class="altcha-floating-arrow"></div>
		{/if}

		{#if !config.setCookie}
			<input type="hidden" form={props.form} name={config.name} value={payload} />
		{/if}
	</div>

	{#if error || currentState === State.EXPIRED || !isSecureContext}
		<Popover
			anchor={elRoot}
			onClickOutside={() => {
				if (isSecureContext) {
					reset();
				}
			}}
			placement={config.popoverPlacement}
			role="alert"
			variant="error"
			{dir}
			{updateUISignal}
		>
			{#if !error && !isSecureContext}
				<div class="altcha-error">Secure context (HTTPS) required.</div>
			{:else if !error && currentState === State.EXPIRED}
				<div class="altcha-error">
					{strings.expired}
				</div>
			{:else}
				<div class="altcha-error" title={error}>
					{strings.error}
				</div>
			{/if}
		</Popover>
	{:else if codeChallenge && currentState === State.CODE}
		{#key codeChallenge}
			<Popover
				anchor={elRoot}
				backdrop={config.codeChallengeDisplay !== 'standard'}
				display={config.codeChallengeDisplay}
				onClose={() => {
					reset();
				}}
				placement={config.popoverPlacement}
				role="dialog"
				aria-label={strings.verificationRequired}
				{dir}
				{updateUISignal}
			>
				<Code
					audioUrl={codeChallengeAudioUrl}
					imageUrl={codeChallengeImageUrl}
					onCancel={() => reset()}
					onReload={() => verify()}
					onSubmit={(code) => verifyServer(code)}
					{codeChallenge}
					{config}
					{strings}
				/>

				{#if showFooter && config.codeChallengeDisplay !== 'standard'}
					<Footer logo={showLogo} {strings} />
				{/if}
			</Popover>
		{/key}
	{/if}
</div>