/** Search-form controls shared by locator, standalone search, and forms. */
import { useCallback, useEffect, useRef, useState, useMemo, createElement, createPortal } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { buildDesignCss, nodeClass, WithIcon } from '@cliqui/design-runtime/designRender';
import SliderControl from '@cliqui/design-runtime/SliderControl';
import { useLocator } from './context';
import { clearLocationSearch, mergeSearchSubmission, normaliseRadius } from './searchState.mjs';
import { buildLocatorSearchUrl } from './searchPermalink.mjs';
import { canApplyGlideValue } from './glideSelection.mjs';
import { modelOf } from './designModels.mjs';
import { geocodeAddress } from './geocode.mjs';


export const GLIDE_FIELD_PRESENTATIONS = {
	select: 'dropdown',
	chips: 'chips',
	accordion: 'accordion',
	popup: 'popup',
	options_list: 'always-open',
};
export const GLIDE_FIELD_TYPES = Object.keys( GLIDE_FIELD_PRESENTATIONS );
export const glidePresentationOf = ( type ) => GLIDE_FIELD_PRESENTATIONS[ type ] || 'dropdown';

const switchSize = ( field ) => [ 'small', 'medium', 'large' ].includes( field?.css?.toggleSize )
	? field.css.toggleSize
	: '';

export const SwitchControl = ( { field, checked, onChange, ariaLabel } ) => {
	const onLabel = String( field.toggleOnLabel || '' );
	const offLabel = String( field.toggleOffLabel || '' );
	const hasInlineLabel = !! ( onLabel || offLabel );
	const size = switchSize( field );
	const classes = [
		'cliq-sl-toggle',
		hasInlineLabel ? 'has-inline-label' : '',
		size ? `is-size-${ size }` : '',
	].filter( Boolean ).join( ' ' );

	return (
		<span className={ nodeClass( classes, field ) }>
			<input type="checkbox" className="cliq-sl-toggle__input" aria-label={ ariaLabel } checked={ checked } onChange={ onChange } />
			<span className="cliq-sl-toggle__track">
				{ offLabel && <span className="cliq-sl-toggle__inline cliq-sl-toggle__inline--off">{ offLabel }</span> }
				{ onLabel && <span className="cliq-sl-toggle__inline cliq-sl-toggle__inline--on">{ onLabel }</span> }
				<span className="cliq-sl-toggle__thumb" />
			</span>
		</span>
	);
};

const GLIDE_THEME_PROPERTIES = {
	color: [ '--glide-color' ],
	fontSize: [ '--glide-font-size' ],
	fontWeight: [ '--glide-font-weight' ],
	textAlign: [ '--glide-text-align' ],
	glidePanelBg: [ '--glide-bg', '--glide-chip-bg' ],
	glideHover: [ '--glide-hover' ],
	glideActive: [ '--glide-active', '--glide-chip-selected-bg', '--glide-tag-bg' ],
	glideActiveColor: [ '--glide-active-color', '--glide-chip-selected-color', '--glide-tag-color' ],
	glidePlaceholder: [ '--glide-placeholder' ],
	glideFocusBorder: [ '--glide-border-focus', '--glide-chip-selected-border' ],
	glideScrollbar: [ '--glide-scrollbar-thumb' ],
	glidePanelRadius: [ '--glide-radius', '--glide-popup-radius', '--glide-chip-radius' ],
};

// Inline presentations place the panel beside the control, while popup/dropdown
// presentations portal it to <body>. Apply the authored tokens to every Glide
// node directly so neither DOM topology nor the library's data-theme defaults
// can prevent a Builder Dropdown-panel setting from taking effect.
const applyGlideTheme = ( instance, css = {} ) => {
	const nodes = [ instance?.dom?.root, instance?.dom?.control, instance?.dom?.dropdown ].filter( Boolean );
	const set = ( property, value ) => nodes.forEach( ( node ) => node.style.setProperty( property, value ) );
	Object.entries( GLIDE_THEME_PROPERTIES ).forEach( ( [ key, properties ] ) => {
		if ( css[ key ] === undefined || css[ key ] === null || css[ key ] === '' ) return;
		let value = String( css[ key ] );
		if ( ( key === 'fontSize' || key === 'glidePanelRadius' ) && /^-?\d*\.?\d+$/.test( value ) ) value += 'px';
		properties.forEach( ( property ) => set( property, value ) );
	} );
	if ( css.fontFamily ) set( '--glide-font-family', `'${ String( css.fontFamily ).replace( /['";{}]/g, '' ) }',sans-serif` );
};

/**
 * Every frontend dropdown (search-form fields, distance/category/brand/sort
 * filters) renders through this one wrapper instead of a bare <select>, so
 * they share one look/behaviour and one place to fix bugs. Glide is a vendored
 * global (window.Glide), not an ES import — same pattern as Leaflet/Google
 * Maps in this file (a WP script dependency guarantees it loads first; see
 * FrontendModule::registerAssets()), not bundled through webpack. Glide mounts on a
 * plain <select> anchor (per its own docs) and owns rendering from there —
 * we pass `options`/`value` directly rather than relying on DOM <option>
 * children, since Preact and Glide would otherwise both try to own that list.
 *
 * `value`/`onChange` are the single souce of truth on OUR side; Glide's own
 * internal selection is kept in sync via setOptions()/setValue() rather than
 * ever being treated as the source of truth itself, so an external query
 * reset (e.g. another widget clearing filters) is reflected here too.
 */
export const GlideSelect = ( { value, onChange, options, placeholder, multiple = false, searchable = false, fuzzy = false, clearable = false, presentation = 'dropdown', showSelectedChips = true, checkboxes = false, panelLabel, staticControlLabel, item = null } ) => {
	const elRef = useRef( null );
	const glideRef = useRef( null );
	const onChangeRef = useRef( onChange );
	onChangeRef.current = onChange;
	// The last selection we emitted upward — used to recognise our OWN change
	// echoing back as a new `value` prop, so the sync effect below never
	// re-applies it (which would re-fire Glide's change and loop forever).
	const lastEmittedRef = useRef( undefined );

	useEffect( () => {
		if ( ! elRef.current || ! window.Glide ) return undefined;
		// A search-form field's BARE n-hook (never nodeClass()'s full output —
		// its leading `cliq-widget` class is design.scss's reset, which would
		// strip Glide's own chrome) goes on Glide's CONTROL, so the builder's
		// generated rule paints the exact element Glide sizes and anchors its
		// panel to, and on the PANEL — portaled to <body>, outside the design
		// scope — which the generated stylesheet themes via the unscoped
		// `.glide-dropdown.cliq-widget-panel-n-<id>` rule (glidePanelVars). The panel
		// additionally gets .cliq-sl-glide-panel, the site-token theme every
		// dropdown panel (widget-level too) falls back to.
		const hook = item ? `cliq-widget-n-${ item.id }` : '';
		const panelHook = item ? `cliq-widget-panel-n-${ item.id }` : '';
		const plugins = [];
		if ( clearable ) plugins.push( window.Glide.plugins.clearButton() );
		if ( checkboxes && window.Glide.plugins.checkboxSelection ) plugins.push( window.Glide.plugins.checkboxSelection() );
		const instance = new window.Glide( elRef.current, {
			options,
			value,
			placeholder,
			multiple,
			searchable,
			fuzzy,
			presentation,
			showSelectedChips,
			staticControlLabel,
			panelLabel,
			// "Back to Any/none" for optional filters — not used on selects that
			// always need a value (distance, sort order).
			plugins,
			className: 'cliq-sl-glide',
			controlClassName: hook || undefined,
			dropdownClassName: `cliq-sl-glide-panel${ panelHook ? ` ${ panelHook }` : '' }`,
		} );
		applyGlideTheme( instance, item?.css );
		// Emit SCALAR values only — Glide can hand back option objects for a
		// multiple control; keeping the stored value as plain strings keeps the
		// submit-time comma join ("5,12") correct instead of "[object Object]".
		instance.on( 'change', ( { value: v } ) => {
			const norm = ( x ) => ( x && typeof x === 'object' ? ( x.value ?? x.id ?? x.label ?? '' ) : x );
			const out = Array.isArray( v ) ? v.map( norm ) : norm( v );
			lastEmittedRef.current = out;
			onChangeRef.current( out );
		} );
		glideRef.current = instance;
		return () => {
			instance.destroy();
			glideRef.current = null;
		};
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [ multiple, searchable, fuzzy, clearable, presentation, showSelectedChips, checkboxes, panelLabel, staticControlLabel, item?.id ] );

	// Builder styles can change without replacing the field or presentation.
	// Re-apply them to the existing instance without destroying selection/open
	// state. The model update replaces the css bag, so this remains cheap.
	useEffect( () => {
		if ( glideRef.current ) applyGlideTheme( glideRef.current, item?.css );
	}, [ item?.css ] );

	// Keep Glide's option list in sync with data that arrives after mount. Guard
	// on CONTENT — the call site recomputes `options` into a new array each
	// render; calling setOptions() every time re-applies the selection and, for
	// a MULTIPLE control, re-fires change -> onChange -> re-render -> loop.
	const lastOptsRef = useRef( '' );
	useEffect( () => {
		if ( ! glideRef.current ) return;
		const k = JSON.stringify( options );
		if ( k === lastOptsRef.current ) return;
		lastOptsRef.current = k;
		const instance = glideRef.current;
		instance.setOptions( options );
		// A URL value can arrive before taxonomy options. Do not ask Glide to
		// select a value it cannot resolve: it normalises that to an empty
		// selection and emits the empty value back into the live search state.
		if ( value !== '' && value != null && canApplyGlideValue( instance.getOptions(), value ) ) {
			lastEmittedRef.current = value;
			instance.setValue( value );
		}
	}, [ options, value ] );

	// Sync Glide's selection when `value` changes from OUTSIDE this component's
	// own onChange. If the incoming value is exactly what we last emitted it's
	// our own change echoing back — skip (re-setting re-fires change and loops,
	// which froze the page). Only a genuinely external value is pushed in.
	useEffect( () => {
		if ( ! glideRef.current ) return;
		const key = ( a ) => JSON.stringify( ( Array.isArray( a ) ? a : a == null || a === '' ? [] : [ a ] ).map( String ).sort() );
		if ( key( value ) === key( lastEmittedRef.current ) ) return;
		if ( ! canApplyGlideValue( glideRef.current.getOptions(), value ) ) return;
		lastEmittedRef.current = value;
		glideRef.current.setValue( value );
	}, [ value ] );

	// The n-hook lands on Glide's control at init (above) — the wrapper stays
	// a plain structural box. The hidden anchor <select> carries the hook too,
	// purely so the SSR pre-swap markup (a visible native select, same hook —
	// see LocatorSsrRenderer::renderFormField) paints identically.
	return (
		<div className="cliq-sl-glidewrap">
			{ /* Glide treats a <select> anchor as a native select and reads its
			     multi-mode from the DOM `multiple` attribute, NOT the config — so
			     the attribute must be present for multiple selection to work. */ }
			<select ref={ elRef } multiple={ multiple || undefined } className={ item ? nodeClass( '', item ) : undefined } />
		</div>
	);
};

// Item types that are actual form inputs (vs. layout items like button/heading).
export const INPUT_FIELD_TYPES = [ 'text', 'email', 'tel', 'number', 'textarea', ...GLIDE_FIELD_TYPES, 'checkbox', 'radio', 'range', 'toggle', 'date' ];

// ---- Search FORM ----------------------------------------------------------
// Renders a multi-field search built in the Search-form builder. Each field
// carries a `mapTo` (search parameter): location/keyword/category/brand/city/country/
// distance. Submitting geocodes any location field, then applies all params.
// Resolve a field's choice source ('static' | 'categories' | 'brands' | 'meta').
export const choiceSourceOf = ( f ) => {
	if ( f.choicesSource ) return f.choicesSource;
	if ( f.mapTo === 'category' ) return 'categories';
	if ( f.mapTo === 'brand' ) return 'brands';
	if ( f.mapTo === 'country' ) return 'countries';
	return 'static';
};

// Generate numeric dropdown options from a field's min/max/step (choicesSource
// 'range') — e.g. a radius field rendered as a select (5, 10, … 50).
export const rangeOptions = ( f, siteUnit = '' ) => {
	const min = Number( f.rangeMin ?? 0 );
	const max = Number( f.rangeMax ?? 0 );
	const step = Math.abs( Number( f.rangeStep ?? 1 ) ) || 1;
	// A radius field labels its options with a unit; without one the dropdown
	// reads a bare "50". The field's own unit is an explicit author override —
	// otherwise follow the SITE's measurement unit, because a saved layout must
	// not hard-code "km" onto a site that measures in miles (the label would
	// then contradict the radius actually being searched).
	const unit = String( f.unit || siteUnit || '' ).trim();
	const out = [];
	for ( let v = min; v <= max && out.length < 1000; v += step ) {
		const s = String( Number.isInteger( v ) ? v : Math.round( v * 100 ) / 100 );
		out.push( { value: s, label: unit ? `${ s } ${ unit }` : s } );
	}
	return out;
};

// A location search field's optional "Address autocomplete (Google)" toggle
// (set per-field on the search form). Independent of the site's chosen map
// provider — Leaflet + Google Places on the address box is a normal
// combination, not a contradiction (see FrontendModule::registerAssets()'s
// $googleForAutocomplete). Polls for window.google.maps.places, matching the
// isProviderReady() retry pattern MapWidget already uses for the map itself,
// rather than a hard script dependency, since this is opt-in per field.
export const LocationAutocompleteInput = ( { value, onChange, onPlaceSelected, placeholder, className, country } ) => {
	const inputRef = useRef( null );
	const autocompleteRef = useRef( null );

	useEffect( () => {
		let cancelled = false;
		let retryTimer = null;
		const tryInit = () => {
			if ( cancelled || ! inputRef.current ) return;
			if ( ! window.google?.maps?.places?.Autocomplete ) {
				retryTimer = setTimeout( tryInit, 200 );
				return;
			}
			// `country` is a static per-field/site setting resolved once by the
			// caller (field override, else the site-wide "Restrict search to
			// country" setting, else worldwide) — not reactive user input, so
			// reading it here at mount time (not in the effect deps) matches
			// this effect's existing "construct once" contract.
			const countryCodes = String( country || '' ).split( ',' ).map( ( c ) => c.trim() ).filter( Boolean ).slice( 0, 5 );
			const ac = new window.google.maps.places.Autocomplete( inputRef.current, {
				fields: [ 'formatted_address', 'geometry' ],
				...( countryCodes.length ? { componentRestrictions: { country: countryCodes } } : {} ),
			} );
			ac.addListener( 'place_changed', () => {
				const place = ac.getPlace();
				const loc = place?.geometry?.location;
				// A place picked from the dropdown already carries a precise
				// lat/lng — skip our own Nominatim geocode round-trip for it.
				if ( loc ) onPlaceSelected( { lat: loc.lat(), lng: loc.lng() }, place.formatted_address || inputRef.current.value );
			} );
			autocompleteRef.current = ac;
		};
		tryInit();
		return () => {
			cancelled = true;
			clearTimeout( retryTimer );
			if ( autocompleteRef.current && window.google?.maps?.event ) {
				window.google.maps.event.clearInstanceListeners( autocompleteRef.current );
			}
			autocompleteRef.current = null;
		};
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [] );

	return <input ref={ inputRef } type="text" className={ className } value={ value } placeholder={ placeholder } onChange={ ( e ) => onChange( e.target.value ) } />;
};

const fieldSeedValue = ( field, value ) => field?.multiple && ! Array.isArray( value )
	? String( value ).split( ',' ).map( ( part ) => part.trim() ).filter( Boolean )
	: value;

export const SearchForm = ( { ws } ) => {
	const { setQuery, designs, config, query, bootstrapData, setGeoError } = useLocator();
	const model = useMemo( () => modelOf( designs, ws.searchFormId ), [ designs, ws.searchFormId ] );
	// Scope class for the shared design stylesheet. Deterministic (form design id)
	// so the SSR markup and the mounted form share the same scoped rules.
	const scope = `cliq-widget-d-f${ ws.searchFormId || 0 }`;
	const configuredResultsPageUrl = String( ws.resultsPageUrl || model?.settings?.resultsPageUrl || '' ).trim();
	// A placement-level URL override explicitly requests redirect submission.
	// Saved forms created before the toggle existed retain their redirect behavior
	// when they already contain a results-page URL; an explicit false disables it.
	const redirectToResultsPage = Boolean( ws.resultsPageUrl )
		|| ( model?.settings?.redirectToResultsPage ?? Boolean( model?.settings?.resultsPageUrl ) );
	const resultsPageUrl = redirectToResultsPage ? configuredResultsPageUrl : '';
	// Distance fields start at the seeded radius (matches the SSR-rendered value
	// so the first client paint doesn't flip the slider/select for a frame).
	const [ vals, setVals ] = useState( () => {
		const seeded = {};
		( model?.rows || [] ).forEach( ( row ) =>
			( row.columns || [] ).forEach( ( col ) =>
				( col.items || [] ).forEach( ( it ) => {
					const key = it.name || it.id;
					const target = String( it.mapTo || '' );
					if ( it.type === 'open_now' ) seeded[ key ] = !! query?.openNow;
					else if ( it.type === 'sort_by' ) seeded[ key ] = query?.sort || 'distance';
					else if ( target === 'distance' && query?.radius != null ) seeded[ key ] = query.radius;
					else if ( target === 'location' && query?.location?.address ) seeded[ key ] = query.location.address;
					else if ( target === 'keyword' && query?.q ) seeded[ key ] = query.q;
					else if ( [ 'category', 'brand', 'city', 'country' ].includes( target ) && query?.[ target ] ) seeded[ key ] = fieldSeedValue( it, query[ target ] );
					else if ( target.startsWith( 'meta:' ) && query?.meta?.[ target.slice( 5 ) ] !== undefined ) seeded[ key ] = fieldSeedValue( it, query.meta[ target.slice( 5 ) ] );
				} )
			)
		);
		return seeded;
	} );
	const [ pendingLocation, setPendingLocation ] = useState( () => query?.location || null );
	// Distinguish a visitor deliberately clearing the location input from an
	// empty field that represents "Use my location". The latter must preserve
	// its coordinates when another filter changes.
	const locationClearedRef = useRef( false );
	// field id -> [{value,label}]; SSR-seeded options skip the /field-options fetch.
	const [ optsMap, setOptsMap ] = useState( () => bootstrapData?.fieldOptions || {} );
	const debounceTimer = useRef( null );
	// Every field edit invalidates an older async geocode submission. Without
	// this guard, a slow lookup for the previous text can restore the location
	// after the visitor has already cleared the field.
	const submissionRevision = useRef( 0 );
	useEffect( () => () => clearTimeout( debounceTimer.current ), [] );

	const fields = useMemo( () => {
		const out = [];
		( model?.rows || [] ).forEach( ( row ) =>
			( row.columns || [] ).forEach( ( col ) =>
				( col.items || [] ).forEach( ( it ) => {
					// Only real input fields — skip layout items (button/heading/
					// text/icon); the form supplies its own Search button.
					if ( INPUT_FIELD_TYPES.includes( it.type ) ) out.push( it );
				} )
			)
		);
		return out;
	}, [ model ] );

	// Seed a distance/range field from the locator's actual radius, so the slider
	// reflects the radius really in use (not its rangeMin) — keeps the displayed
	// value and the query in sync on first load.
	useEffect( () => {
		setVals( ( prev ) => {
			const next = { ...prev };
			fields.forEach( ( f ) => {
				const key = f.name || f.id;
				if ( next[ key ] !== undefined ) return; // don't clobber user input
				const target = String( f.mapTo || '' );
				if ( f.type === 'open_now' ) next[ key ] = !! query?.openNow;
				else if ( f.type === 'sort_by' ) next[ key ] = query?.sort || 'distance';
				else if ( target === 'distance' && query?.radius != null ) next[ key ] = query.radius;
				else if ( target === 'location' && query?.location?.address ) next[ key ] = query.location.address;
				else if ( target === 'keyword' && query?.q ) next[ key ] = query.q;
				else if ( [ 'category', 'brand', 'city', 'country' ].includes( target ) && query?.[ target ] ) next[ key ] = fieldSeedValue( f, query[ target ] );
				else if ( target.startsWith( 'meta:' ) && query?.meta?.[ target.slice( 5 ) ] !== undefined ) next[ key ] = fieldSeedValue( f, query.meta[ target.slice( 5 ) ] );
			} );
			return next;
		} );
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [ fields ] );

	// Load dynamic options (taxonomies, countries, or distinct field values).
	// Fields the SSR bootstrap already resolved are skipped.
	useEffect( () => {
		fields.forEach( ( f ) => {
			const src = choiceSourceOf( f );
			if ( src === 'static' || src === 'range' ) return;
			// An EMPTY seeded array is not "already resolved": [] is truthy, so
			// this used to latch a field on "No options" forever whenever the
			// SSR pass resolved nothing (e.g. a stale category scope). Only a
			// non-empty list counts as resolved.
			if ( optsMap[ f.id ]?.length ) return;
			let url = `${ config.restUrl }/field-options?source=${ src }`;
			if ( src === 'meta' ) {
				const key = f.choicesMetaKey || ( String( f.mapTo || '' ).startsWith( 'meta:' ) ? f.mapTo.slice( 5 ) : '' );
				if ( ! key ) return;
				url += `&key=${ encodeURIComponent( key ) }`;
			} else if ( src === 'store_field' ) {
				if ( ! f.choicesStoreField ) return;
				url += `&key=${ encodeURIComponent( f.choicesStoreField ) }`;
				// A city list on a single-country locator should offer only that
				// country's cities.
				if ( f.choicesCountry ) {
					url += `&scope=${ encodeURIComponent( f.choicesCountry ) }`;
				}
			} else if ( src === 'categories' && f.choicesParent ) {
				// Scope the facet to one branch of the category tree, so a
				// placement for one kind of business does not offer every
				// category on the site.
				url += `&key=${ encodeURIComponent( f.choicesParent ) }`;
			}
			fetch( url )
				.then( ( r ) => r.json() )
				.then( ( res ) => setOptsMap( ( m ) => ( { ...m, [ f.id ]: res?.options || [] } ) ) )
				.catch( () => {} );
		} );
	}, [ fields, config.restUrl ] );

	if ( ! model ) return null;

	// Geolocation needs a secure context (https, or localhost/*.test in most
	// browsers) — outside one, navigator.geolocation is simply undefined, and
	// there was previously no error callback at all, so a permission denial or
	// timeout silently did nothing (looked "broken" with zero feedback).
	const locateMe = ( attachedFieldKey = '' ) => {
		setGeoError( '' );
		if ( ! navigator.geolocation ) {
			setGeoError( __( 'Your browser (or this page\'s connection) doesn\'t support location access.', 'cliq-store-locator' ) );
			return;
		}
		navigator.geolocation.getCurrentPosition(
			( pos ) => {
				clearTimeout( debounceTimer.current );
				setGeoError( '' );
				if ( attachedFieldKey ) {
					setVals( ( current ) => ( { ...current, [ attachedFieldKey ]: '' } ) );
				}
				const location = { lat: pos.coords.latitude, lng: pos.coords.longitude, source: 'geolocation' };
				locationClearedRef.current = false;
				setPendingLocation( location );
				if ( ! redirectToResultsPage ) setQuery( ( q ) => ( { ...q, location } ) );
			},
			( err ) => {
				const messages = {
					1: __( 'Location access was denied. Allow it in your browser\'s address-bar permissions and try again.', 'cliq-store-locator' ),
					2: __( 'Your location could not be determined.', 'cliq-store-locator' ),
					3: __( 'Finding your location took too long. Please try again.', 'cliq-store-locator' ),
				};
				const message = messages[ err.code ] || __( 'Could not access your location.', 'cliq-store-locator' );
				if ( err.code === 1 && navigator.permissions?.query ) {
					navigator.permissions.query( { name: 'geolocation' } )
						.then( ( permission ) => setGeoError( permission.state === 'granted' ? '' : message ) )
						.catch( () => setGeoError( message ) );
				} else {
					setGeoError( message );
				}
			},
			{ timeout: 10000 }
		);
	};
	const setField = ( key, v ) => setVals( ( p ) => ( { ...p, [ key ]: v } ) );
	const optionsFor = ( f ) => {
		const src = choiceSourceOf( f );
		if ( src === 'static' ) return f.options || [];
		if ( src === 'range' ) return rangeOptions( f, query?.unit || config?.unit || '' );
		return optsMap[ f.id ] || [];
	};

	const submit = async () => {
		// An explicit submit supersedes any debounced one already queued by typing.
		// Without this, "type an address then press Search" runs the whole
		// submission twice: the revision guard below discards the stale RESULT,
		// but the geocode request has already gone out — two calls to a public
		// geocoder for one search, against its usage policy.
		clearTimeout( debounceTimer.current );
		const revision = ++submissionRevision.current;
		// Keep coordinates supplied by Use my location or another location
		// control. This form only replaces them when it resolves a new location.
		const patch = { category: '', brand: '', city: '', country: '', q: '' };
		const meta = {};
		const metaOps = {};
		let radius;
		for ( const f of fields ) {
			const key = f.name || f.id;
			let v = vals[ key ];
			if ( f.type === 'open_now' ) {
				patch.openNow = !! v;
				continue;
			}
			if ( f.type === 'sort_by' ) {
				patch.sort = String( v || 'distance' );
				continue;
			}
			if ( f.type === 'toggle' ) v = v ? ( f.onValue || 'yes' ) : '';
			if ( Array.isArray( v ) ) {
				const list = v.filter( ( x ) => x !== '' && x != null );
				if ( ! list.length ) continue;
				v = list.join( ',' );
			}
			const param = f.mapTo || '';
			if ( param === 'location' && ( v === undefined || String( v ).trim() === '' ) ) {
				if ( locationClearedRef.current ) patch.location = null;
				continue;
			}
			if ( v === undefined || v === '' ) continue;
			if ( param.startsWith( 'meta:' ) ) {
				const mk = param.slice( 5 );
				meta[ mk ] = String( v );
				metaOps[ mk ] = f.op || '=';
				continue;
			}
			switch ( param ) {
			case 'location': {
					if ( pendingLocation?.lat != null && pendingLocation?.lng != null && ( ! pendingLocation.address || pendingLocation.address === String( v ) ) ) {
						patch.location = pendingLocation;
						break;
					}
					const ll = await geocodeAddress( String( v ), f.country || config?.restrictCountry );
					if ( revision !== submissionRevision.current ) return;
					// Geocoded → search by radius around that point. Only fall back
					// to a keyword match when geocoding the address fails (otherwise
					// "mumbai" would also be required as a name/text match → 0 hits).
					if ( ll ) {
						patch.location = { ...ll, source: 'address', address: String( v ) };
					} else {
						patch.location = null;
						patch.q = String( v );
					}
					break;
				}
				case 'keyword': patch.q = String( v ); break;
				case 'category': patch.category = v; break;
				case 'brand': patch.brand = v; break;
				case 'city': patch.city = String( v ); break;
				case 'country': patch.country = String( v ); break;
				case 'distance': radius = Number( v ); break;
				default: break;
			}
		}
		if ( revision !== submissionRevision.current ) return;
		const nextQuery = mergeSearchSubmission( { ...query, location: pendingLocation || query.location }, { ...patch, meta, metaOps }, radius );
		if ( redirectToResultsPage ) {
			if ( ! resultsPageUrl ) {
				setGeoError( __( 'Choose a Search results page before using redirected search.', 'cliq-store-locator' ) );
				return;
			}
			window.location.assign( buildLocatorSearchUrl( resultsPageUrl, nextQuery, window.location.href ) );
			return;
		}
		setQuery( nextQuery );
	};

	// Live/AJAX search — never a full page reload; a field change re-runs the
	// search on its own instead of requiring an explicit Search click (the
	// button, if placed, still works — it's an optional "do it now" shortcut,
	// not a requirement). `submitRef` always points at the CURRENT render's
	// `submit` (which closes over the current `vals`) so the debounced call
	// below picks up the latest values even though it was scheduled by an
	// earlier keystroke's render — the classic setTimeout-stale-closure trap.
	const submitRef = useRef( null );
	submitRef.current = submit;
	const scheduleSubmit = ( delay ) => {
		clearTimeout( debounceTimer.current );
		debounceTimer.current = setTimeout( () => submitRef.current(), delay );
	};

	// Typed/dragged fields (text/location/keyword/meta inputs, a range slider)
	// debounce so a geocode request (or a search) doesn't fire on every single
	// keystroke/drag-frame; discrete controls (select/checkbox/radio/toggle)
	// fire almost immediately — no such rapid-fire concern for those.
	const isTypedField = ( f ) =>
		[ 'text', 'email', 'tel', 'number', 'textarea', 'range' ].includes( f.type )
		|| f.mapTo === 'location' || f.mapTo === 'keyword' || String( f.mapTo || '' ).startsWith( 'meta:' );
	const onFieldChange = ( f, v ) => {
		// Invalidate a geocode that may already be in flight before applying this
		// newer field value.
		submissionRevision.current += 1;
		if ( f.mapTo === 'location' ) {
			const cleared = String( v ?? '' ).trim() === '';
			locationClearedRef.current = cleared;
			setPendingLocation( null );
			if ( cleared && ! redirectToResultsPage ) {
				clearTimeout( debounceTimer.current );
				setField( f.name || f.id, v );
				// A dedicated keyword field is independent of the location field.
				// Preserve it; otherwise remove the keyword fallback that a failed
				// location geocode may have put into the query.
				const keywordField = fields.find( ( field ) => field.mapTo === 'keyword' );
				const keyword = keywordField ? vals[ keywordField.name || keywordField.id ] : '';
				setQuery( ( current ) => clearLocationSearch( current, keyword ) );
				return;
			}
		}
		setField( f.name || f.id, v );
		// A form with a Results page is a conventional submit form: changing a
		// field must not navigate away before the visitor presses Search.
		if ( ! redirectToResultsPage ) scheduleSubmit( isTypedField( f ) ? 500 : 120 );
	};

	const renderField = ( f ) => {
		const key = f.name || f.id;
		const label = f.label || '';
		const fluidControl = [ 'textarea', 'chips', 'accordion', 'options_list', 'checkbox', 'radio' ].includes( f.type );
		const wrap = ( inner ) => (
			<label className="cliq-sl-ff" key={ f.id }>
				{ /* hideLabel (per-field builder toggle) hides the visible label
				     only — placeholder/aria still identify the field. */ }
				{ label && ! f.hideLabel && <span className="cliq-sl-w__label">{ label }</span> }
				<span className={ `cliq-sl-ff__control${ fluidControl ? ' is-fluid-control' : '' } cliq-sl-field-shell-${ f.id }` }>{ inner }</span>
			</label>
		);
		const withAttachedLocation = ( input ) => ! f.showUseMyLocation ? input : (
			<span className="cliq-sl-location-control">
				{ input }
				<button type="button" className="cliq-sl-location-action" title={ __( 'Use my location', 'cliq-store-locator' ) } aria-label={ __( 'Use my location', 'cliq-store-locator' ) } onClick={ () => locateMe( key ) }>
					<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="7" /><circle cx="12" cy="12" r="2" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3" /></svg>
				</button>
			</span>
		);

		// Buttons are placeable fields — rendered wherever the builder put them.
		// nodeClass() attaches the per-item css hook so the builder's Colour / icon
		// styling lands on the actual <button> (just like a Directions/Details button).
		if ( f.type === 'submit' ) {
			return (
				<button key={ f.id } type="submit" className={ nodeClass( 'cliq-sl-search__btn', f ) }>
					<WithIcon item={ f }>{ f.label || __( 'Search', 'cliq-store-locator' ) }</WithIcon>
				</button>
			);
		}
		if ( f.type === 'geocode' ) {
			const gl = f.label || __( 'Use my location', 'cliq-store-locator' );
			// Show label and/or icon when set; fall back to the ◎ glyph otherwise.
			// Sizing/padding/etc. is the builder's job (nodeClass attaches this
			// item's own cliq-widget-n-<id> CSS hook, same as any other button) —
			// not something to special-case in the stylesheet.
			const body = ( f.icon || f.label )
				? <WithIcon item={ f }>{ f.label || '' }</WithIcon>
				: <span aria-hidden="true">◎</span>;
			return (
				<button key={ f.id } type="button" className={ nodeClass( 'cliq-sl-search__geo', f ) } onClick={ () => locateMe() } title={ gl } aria-label={ gl }>
					{ body }
				</button>
			);
		}
		// Self-configuring, like submit/geocode above: write straight to the
		// shared query state instead of the generic vals/onFieldChange (meta
		// filter) pipeline — replaces the retired standalone Open-now/Sort
		// widgets, which lived outside search forms entirely.
		if ( f.type === 'open_now' ) {
			const openNowLabel = f.label || __( 'Open now', 'cliq-store-locator' );
			return (
				<label key={ f.id } className="cliq-sl-ff cliq-sl-open-now-field">
					{ ! f.hideLabel && <span className="cliq-sl-w__label">{ openNowLabel }</span> }
					<span className={ `cliq-sl-ff__control cliq-sl-field-shell-${ f.id }` }>
						<SwitchControl field={ f } ariaLabel={ openNowLabel } checked={ redirectToResultsPage ? !! vals[ key ] : !! query.openNow } onChange={ ( e ) => redirectToResultsPage ? setField( key, e.target.checked ) : setQuery( ( q ) => ( { ...q, openNow: e.target.checked } ) ) } />
					</span>
				</label>
			);
		}
		if ( f.type === 'sort_by' ) {
			return wrap(
				<GlideSelect
						options={ [
							{ value: 'distance', label: __( 'Distance', 'cliq-store-locator' ) },
							{ value: 'name', label: __( 'Name', 'cliq-store-locator' ) },
						] }
						value={ redirectToResultsPage ? ( vals[ key ] || 'distance' ) : ( query.sort || 'distance' ) }
						onChange={ ( v ) => redirectToResultsPage ? setField( key, v || 'distance' ) : setQuery( ( q ) => ( { ...q, sort: v || 'distance' } ) ) }
						item={ f }
				/>
			);
		}

		if ( GLIDE_FIELD_TYPES.includes( f.type ) ) {
			const options = optionsFor( f ).map( ( o ) => ( { value: String( o.value ?? o ), label: String( o.label ?? o ) } ) );
			const multi = !! f.multiple;
			const presentation = glidePresentationOf( f.type );
			return wrap(
				<GlideSelect
					options={ options }
					multiple={ multi }
					value={ multi ? ( Array.isArray( vals[ key ] ) ? vals[ key ] : [] ) : ( vals[ key ] || '' ) }
					onChange={ ( v ) => onFieldChange( f, multi ? ( v || [] ) : ( v || '' ) ) }
					placeholder={ f.placeholder || __( 'Any', 'cliq-store-locator' ) }
					searchable={ presentation !== 'chips' && options.length > 8 }
					fuzzy={ presentation !== 'chips' && options.length > 8 }
					clearable
					presentation={ presentation }
					showSelectedChips={ f.showSelectedChips !== false }
					checkboxes={ presentation === 'always-open' && multi }
					panelLabel={ presentation === 'popup' && f.popupShowPanelHeader !== false ? ( f.popupPanelLabel || f.label || undefined ) : undefined }
					staticControlLabel={ presentation === 'accordion'
						? ( f.accordionLabel || f.label || f.placeholder || __( 'Options', 'cliq-store-locator' ) )
						: presentation === 'popup' && f.popupStaticTrigger
							? ( f.popupTriggerLabel || f.label || f.placeholder || __( 'Select options', 'cliq-store-locator' ) )
							: undefined }
					item={ f }
				/>
			);
		}
		if ( f.type === 'radio' || f.type === 'checkbox' ) {
			const options = optionsFor( f );
			return wrap(
				<span className={ nodeClass( 'cliq-sl-choices', f ) }>
					{ options.map( ( o, i ) => (
						<label className="cliq-sl-choice" key={ i }>
							<input type={ f.type } name={ key } value={ o.value ?? o } className="cliq-sl-choice__input"
								checked={ f.type === 'radio' ? vals[ key ] === ( o.value ?? o ) : ( vals[ key ] || [] ).includes?.( o.value ?? o ) }
								onChange={ ( e ) => onFieldChange( f, e.target.value ) } />
							<span className={ `cliq-sl-choice__box cliq-sl-choice__box--${ f.type }` } />
							<span className="cliq-sl-choice__label">{ o.label ?? o }</span>
						</label>
					) ) }
				</span>
			);
		}
		if ( f.type === 'toggle' ) {
			return wrap(
				<SwitchControl
					field={ f }
					ariaLabel={ label || __( 'Toggle', 'cliq-store-locator' ) }
					checked={ !! vals[ key ] }
					onChange={ ( e ) => onFieldChange( f, e.target.checked ) }
				/>
			);
		}
		if ( f.type === 'range' ) {
			const min = f.rangeMin ?? 0;
			const max = f.rangeMax ?? 100;
			const val = Number( vals[ key ] ?? min );
			return wrap(
				<span className={ nodeClass( 'cliq-sl-rangewrap', f ) }>
					<SliderControl min={ min } max={ max } step={ f.rangeStep ?? 1 } value={ val } onChange={ ( v ) => onFieldChange( f, v ) } hookClass={ `cliq-widget-n-${ f.id }` } />
					<span className="cliq-sl-rangeval">{ ( f.unit || query?.unit || config?.unit ) ? `${ val } ${ f.unit || query?.unit || config?.unit }` : val }</span>
				</span>
			);
		}
		if ( f.mapTo === 'location' && f.autocomplete ) {
			return wrap(
				withAttachedLocation( <LocationAutocompleteInput
					className={ nodeClass( 'cliq-sl-location-input', f ) }
					value={ vals[ key ] || '' }
					placeholder={ f.placeholder || '' }
					country={ f.autocompleteCountry || config?.restrictCountry || '' }
					onChange={ ( v ) => onFieldChange( f, v ) }
					onPlaceSelected={ ( ll, address ) => {
						// A picked suggestion already carries a precise lat/lng —
						// apply it immediately rather than waiting for the
						// debounced Nominatim geocode this field would otherwise get.
						// Cancel that pending debounce too: it was scheduled by the
						// keystrokes typed before picking a suggestion, and — being
						// a plain text geocode of a possibly-partial address — could
						// still fire afterwards and overwrite this precise result.
						clearTimeout( debounceTimer.current );
						setField( key, address );
						const location = { ...ll, source: 'address', address };
						locationClearedRef.current = false;
						setPendingLocation( location );
						if ( ! redirectToResultsPage ) setQuery( ( q ) => ( { ...q, location } ) );
					} }
				/> )
			);
		}
		if ( f.mapTo === 'location' && f.showUseMyLocation ) {
			return wrap( withAttachedLocation(
				<input type="text" className={ nodeClass( 'cliq-sl-location-input', f ) } value={ vals[ key ] || '' } placeholder={ f.placeholder || '' } onChange={ ( e ) => onFieldChange( f, e.target.value ) } />
			) );
		}
		return wrap(
			<input type={ f.type === 'number' ? 'number' : 'text' } className={ nodeClass( 'cliq-sl-textinput', f ) } value={ vals[ key ] || '' } placeholder={ f.placeholder || '' } onChange={ ( e ) => onFieldChange( f, e.target.value ) } />
		);
	};

	// Render through the SHARED design grid (cliq-widget-*) exactly like the
	// builder preview (GridPreview): buildDesignCss() emits the scoped per-column /
	// per-item spans + spanMobile responsiveness at the configured breakpoint, so
	// the live form is identical to the canvas. Only the field content is ours.
	const css = buildDesignCss( model, `.${ scope }`, 'desktop', config?.mobileBreakpoint, [] );

	return (
		<div className="cliq-sl-w cliq-sl-w--search">
			<form className={ `cliq-widget cliq-widget-design cliq-widget-design--form cliq-sl-searchform ${ scope }` } onSubmit={ ( e ) => { e.preventDefault(); submit(); } }>
				{ css ? <style>{ css }</style> : null }
				{ ( model.rows || [] ).map( ( row ) => (
					<div className={ nodeClass( 'cliq-widget-row', row ) } key={ row.id }>
						{ ( row.columns || [] ).map( ( col ) => (
							<div className={ nodeClass( 'cliq-widget-col', col ) } key={ col.id }>
								{ ( col.items || [] ).map( ( it ) => (
									<div className={ `cliq-widget cliq-widget-cell cliq-widget-cl-${ it.id }` } key={ it.id }>
										{ renderField( it ) }
									</div>
								) ) }
							</div>
						) ) }
					</div>
				) ) }
			</form>
		</div>
	);
};

// The search widget always renders a Search form from its saved layout.
