/**
 * LocatorApp — the visitor-facing store locator. Fetches a saved locator
 * template (its widget grid + the referenced list_item/infobox design models),
 * owns the shared query state, runs the spatial search, and renders the same
 * rows/columns grid the admin built — but with live, interactive widgets.
 */
import { useEffect, useMemo, useRef, useState, useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { buildDesignCss, nodeClass, resultsContainerVars, loadMoreButtonVars, WithIcon } from '@cliqui/design-runtime/designRender';
import { ensureFonts, loadGoogleFont } from '@cliqui/design-runtime/fonts';
import { LocatorContext } from './context';
import { WIDGETS } from './widgets';
import { buildSearchUrl, normaliseRadius } from './searchState.mjs';
import { applyLocatorSearch } from './searchPermalink.mjs';
import { cacheIpLocation, normaliseIpLocation, readCachedIpLocation } from './ipLocation.mjs';

const cssToken = ( value ) => String( value || '' ).replace( /[;{}<>]/g, '' );

const groupedAccordionVars = ( ws ) => [
	ws.groupClosedBackground ? `--cliq-group-closed-bg:${ cssToken( ws.groupClosedBackground ) }` : '',
	ws.groupOpenBackground ? `--cliq-group-open-bg:${ cssToken( ws.groupOpenBackground ) }` : '',
	ws.groupCountBackground ? `--cliq-group-count-bg:${ cssToken( ws.groupCountBackground ) }` : '',
	ws.groupCountColor ? `--cliq-group-count-color:${ cssToken( ws.groupCountColor ) }` : '',
	ws.groupCountRadius ? `--cliq-group-count-radius:${ cssToken( ws.groupCountRadius ) }` : '',
	ws.groupCountFontFamily ? `--cliq-group-count-font-family:${ cssToken( ws.groupCountFontFamily ) }` : '',
	ws.groupCountFontSize ? `--cliq-group-count-font-size:${ cssToken( ws.groupCountFontSize ) }` : '',
	ws.groupCountFontWeight ? `--cliq-group-count-font-weight:${ cssToken( ws.groupCountFontWeight ) }` : '',
].filter( Boolean ).join( ';' );

const categoryChipVars = ( ws ) => [
	ws.chipBackground ? `--cliq-chip-bg:${ cssToken( ws.chipBackground ) }` : '',
	ws.chipColor ? `--cliq-chip-color:${ cssToken( ws.chipColor ) }` : '',
	ws.chipBorderColor ? `--cliq-chip-border:${ cssToken( ws.chipBorderColor ) }` : '',
	ws.chipHoverBackground ? `--cliq-chip-hover-bg:${ cssToken( ws.chipHoverBackground ) }` : '',
	ws.chipHoverColor ? `--cliq-chip-hover-color:${ cssToken( ws.chipHoverColor ) }` : '',
	ws.chipActiveBackground ? `--cliq-chip-active-bg:${ cssToken( ws.chipActiveBackground ) }` : '',
	ws.chipActiveColor ? `--cliq-chip-active-color:${ cssToken( ws.chipActiveColor ) }` : '',
	ws.chipActiveBorderColor ? `--cliq-chip-active-border:${ cssToken( ws.chipActiveBorderColor ) }` : '',
].filter( Boolean ).join( ';' );

// Parse a design model that may arrive as a JSON string or an object.
const parseModel = ( m ) => {
	if ( ! m ) return null;
	if ( typeof m !== 'string' ) return m;
	try { return JSON.parse( m ); } catch ( e ) { return null; }
};

/**
 * Widget SETTINGS → generated scoped CSS rules, keyed on each widget item's
 * own n-hook: element settings become stylesheet rules,
 * never style attributes. Values are emitted as the custom properties the
 * base rules consume (.cliq-widget-results / .cliq-sl-map-wrap /
 * .cliq-sl-catgrid), appended to the template's generated stylesheet.
 * PHP SSR twin: LocatorSsrRenderer::widgetSettingsCss() — keep in sync.
 */
const widgetSettingsCss = ( model, scope ) => {
	const rules = [];
	( model?.rows || [] ).forEach( ( row ) =>
		( row.columns || [] ).forEach( ( col ) =>
			( col.items || [] ).forEach( ( item ) => {
				if ( item.type !== 'widget' ) return;
				const ws = item.widgetSettings || {};
				let decls = '';
				if ( item.widget === 'results' ) {
					// The accordion's list default gap (12) differs from the flex/grid
					// default (14, the base rule's fallback) — normalise here so the
					// container components stay settings-free.
					const mode = ws.displayMode || 'list';
					const containerDecls = resultsContainerVars( [ 'accordion', 'grouped' ].includes( mode ) ? { ...ws, layout: 'list', gap: ws.gap ?? 12 } : ws );
					const buttonDecls = loadMoreButtonVars( ws );
					const groupDecls = groupedAccordionVars( ws );
					const countColorDecl = cssToken( ws.resultCountColor )
						? `--cliq-sl-count-color:${ cssToken( ws.resultCountColor ) }`
						: '';
					decls = [ containerDecls, buttonDecls, groupDecls, countColorDecl ].filter( Boolean ).join( ';' );
				} else if ( item.widget === 'count' ) {
					// Without this the count inherits --cliq-sl-on-canvas and a dark
					// kit has no way to make it legible. Twin: renderWidgetVars() PHP.
					const countColor = cssToken( ws.textColor );
					decls = countColor ? `--cliq-sl-count-color:${ countColor }` : '';
				} else if ( item.widget === 'category' ) {
					decls = ws.display === 'tiles' ? resultsContainerVars( ws ) : categoryChipVars( ws );
				} else if ( item.widget === 'map' ) {
					const markerNavBg = cssToken( ws.markerNavigationBackground ) || 'var(--cliq-sl-surface)';
					const requestedMarkerNavColor = cssToken( ws.markerNavigationColor ) || 'var(--cliq-sl-primary)';
					const markerNavColor = requestedMarkerNavColor.toLowerCase() === markerNavBg.toLowerCase()
						? 'var(--cliq-sl-on-primary)'
						: requestedMarkerNavColor;
					decls = [
						`--cliq-sl-map-height:${ parseInt( ws.height ) || 480 }px`,
						`--cliq-map-search-top:${ ws.searchOverlayTop == null ? 20 : Math.max( 0, parseInt( ws.searchOverlayTop ) || 0 ) }px`,
						`--cliq-map-search-width:${ parseInt( ws.searchOverlayWidth ) || 640 }px`,
						`--cliq-marker-nav-color:${ markerNavColor }`,
						`--cliq-marker-nav-bg:${ markerNavBg }`,
					].join( ';' );
				}
				if ( decls ) rules.push( `${ scope } .cliq-widget-n-${ item.id }{${ decls }}` );
			} )
		)
	);
	return rules.join( '\n' );
};

const findWidget = ( model, name ) => {
	for ( const row of model?.rows || [] ) {
		for ( const col of row.columns || [] ) {
			for ( const item of col.items || [] ) {
				if ( item.type === 'widget' && item.widget === name ) return item.widgetSettings || {};
			}
		}
	}
	return null;
};

// The initial shared query. No location on load — the initial list is a plain
// browse (no spatial filter). A location is only set when the visitor searches
// an address, uses "my location", or geolocation mode is on. The MAP centres
// itself from its own centerLat/centerLng, independent of the query.
const initialQuery = ( config ) => ( {
	location: null,
	radius: 50,
	unit: config.unit || 'km',
	city: '',
	country: '',
	category: '',
	brand: '',
	q: '',
	openNow: false,
	sort: 'distance',
	meta: {},
	metaOps: {},
	pageSize: 50,
	statuses: [ 'active' ],
} );

// Seed mutable visitor query state from the template's distance/results
// widgets. Placement filters remain immutable in config.defaults and are sent
// separately by buildSearchUrl(), so a search-form submission cannot replace
// shortcode/block/widget constraints. Shared by the
// SSR-bootstrap initializer and the template-fetch path so both produce the
// same query (SSR markup and hydrated state must match).
const seedQuery = ( model, config, base ) => {
	const dist = findWidget( model, 'distance' );
	const results = findWidget( model, 'results' );
	const defaults = config.defaults || {};
	// Placement page-size override (shortcode per_page / block perPage) beats
	// the results widget's own pageSize — mirrors the SSR renderLocatorSsr().
	const perPage = parseInt( defaults.perPage, 10 );
	const placed = {
		...base,
		radius: normaliseRadius( dist?.default, base.radius ),
		unit: dist?.unit ?? base.unit,
		pageSize: perPage > 0 ? perPage : ( results?.pageSize ?? base.pageSize ),
		statuses: ( Array.isArray( results?.statuses ) && results.statuses.length ) ? results.statuses : base.statuses,
	};
	return applyLocatorSearch( placed, typeof window !== 'undefined' ? window.location.search : '' );
};

const LocatorApp = ( { templateId, config, bootstrap = null } ) => {
	// Server-side (SSR) bootstrap: PHP inlined the /templates/{id} payload and the
	// first-page /search response, so ALL initial state is seeded synchronously in
	// the initializers below — no template fetch, no first search, no loading
	// frame. Seeding post-mount instead (setQuery) would change runSearch's
	// identity and defeat the first-search skip.
	const boot = bootstrap && bootstrap.template ? bootstrap : null;
	const bootModel = boot ? parseModel( boot.template.model ) : null;
	const [ template, setTemplate ] = useState( bootModel );
	// Merges the template's own designs (list_item/infobox/search) with any HTML
	// marker designs the SSR bootstrap's first-page search resolved — the latter
	// are per-store/category dynamic, not fixed to one widget setting, so they
	// travel on the search response instead (see runSearch() below).
	const [ designs, setDesigns ] = useState( () => ( {
		...( boot?.template?.designs || {} ),
		...( boot?.search?.designs || {} ),
	} ) );
	const [ error, setError ] = useState( null );
	const [ searchError, setSearchError ] = useState( false );
	// Shared with any widget (e.g. the "use my location" button) that needs to
	// surface a non-search failure in the SAME top-of-locator banner, rather
	// than each widget inventing its own inline error slot.
	const [ geoError, setGeoError ] = useState( '' );
	const [ locationPromptOpen, setLocationPromptOpen ] = useState( false );
	const [ locating, setLocating ] = useState( false );
	const [ results, setResults ] = useState( () =>
		( boot?.search?.results || [] ).map( ( s ) => ( {
			...s,
			distanceUnit: boot?.search?.unit || config.unit || 'mi',
		} ) )
	);
	const [ total, setTotal ] = useState( boot?.search?.total ?? 0 );
	// Per-category counts scoped to the current search (radius/location/filters),
	// returned by /search when the category browser is at its top level. null =
	// no active narrowing → the browser falls back to global /categories counts.
	const [ categoryCounts, setCategoryCounts ] = useState( boot?.search?.categoryCounts ?? null );
	const [ loading, setLoading ] = useState( false );
	const [ refreshing, setRefreshing ] = useState( false );
	const [ selectedId, setSelectedId ] = useState( null );
	// 'split' (list + map) | 'list' | 'map'. Was a mapOnly boolean; the third
	// state is what the view switcher needs, and mapOnly is derived from it so
	// every existing consumer keeps working unchanged.
	const [ view, setView ] = useState( 'split' );
	// Apply the layout's configured starting view once the template is known,
	// without stamping over a choice the visitor has already made.
	const viewSeeded = useRef( false );
	const mapOnly = view === 'map';
	const listOnly = view === 'list';
	const [ mapTransitioned, setMapTransitioned ] = useState( false );
	const [ query, setQuery ] = useState( () => {
		const base = initialQuery( config );
		return bootModel
			? seedQuery( bootModel, config, base )
			: applyLocatorSearch( base, typeof window !== 'undefined' ? window.location.search : '' );
	} );

	// The top-level template grid (row/column/cell arranging Search/Results/Map
	// widgets) is rendered through the SAME shared buildDesignCss() every other
	// builder-placed row/column/item uses (a Design's own rows/columns/items) —
	// not a hand-rolled, inline-styled parallel version. Structural grid CSS +
	// any settings.css the admin set + responsive spanMobile all land in one
	// scoped stylesheet under nodeClass()-attached cliq-widget-n-<id> hooks.
	// Deterministic scope (template id), matching the SSR markup so the client
	// swap keeps the same classes.
	const layoutScope = `cliq-sl-l-t${ templateId }`;
	const layoutCss = useMemo(
		() => {
			if ( ! template ) return '';
			const resultsSettings = findWidget( template, 'results' ) || {};
			// The map toggle and the filter button are the two controls the layout
			// owns but the SITE palette used to colour, so a green layout showed a
			// blue button. Both now fall back to the site primary only when the
			// layout has not set its own.
			const toggleVars = `.${ layoutScope }{`
				+ `--cliq-map-toggle-color:${ cssToken( resultsSettings.mapToggleColor ) || 'var(--cliq-sl-on-primary)' };`
				+ `--cliq-map-toggle-bg:${ cssToken( resultsSettings.mapToggleBackground ) || 'var(--cliq-sl-primary)' };`
				+ `--cliq-filter-btn-color:${ cssToken( resultsSettings.filterButtonColor ) || 'inherit' };`
				+ `--cliq-filter-btn-bg:${ cssToken( resultsSettings.filterButtonBackground ) || 'transparent' };`
				+ `--cliq-filter-btn-border:${ cssToken( resultsSettings.filterButtonBorderColor ) || 'var(--cliq-sl-border-soft)' };`
				+ `--cliq-filter-btn-radius:${ cssToken( resultsSettings.filterButtonRadius ) || '0' }`
				+ `}`;
			return buildDesignCss( template, `.${ layoutScope }`, 'desktop', config?.mobileBreakpoint, [] )
				+ '\n' + widgetSettingsCss( template, `.${ layoutScope }` )
				+ '\n' + toggleVars;
		},
		[ template, layoutScope, config ]
	);
	const mapSettings = useMemo( () => findWidget( template, 'map' ) || {}, [ template ] );

	// Load the template + embedded designs once (skipped when the SSR bootstrap
	// already seeded them — that's the whole point of server-side mode).
	useEffect( () => {
		if ( boot ) return undefined;
		let live = true;
		fetch( `${ config.restUrl }/templates/${ templateId }${ config.lang ? `?lang=${ config.lang }` : '' }` )
			.then( ( r ) => ( r.ok ? r.json() : Promise.reject( r ) ) )
			.then( ( data ) => {
				if ( ! live ) return;
				const model = typeof data.model === 'string' ? JSON.parse( data.model ) : data.model;
				setTemplate( model );
				setDesigns( ( d ) => ( { ...d, ...( data.designs || {} ) } ) );
				// Seed non-spatial query defaults from the distance/results widgets.
				// We deliberately do NOT seed a location from the map centre — the
				// initial list is a browse; the map centres itself from its own
				// settings. Optional default filters (country/category/brand) come
				// from the shortcode/block via config.defaults.
				setQuery( ( q ) => seedQuery( model, config, q ) );
			} )
			.catch( () => live && setError( __( 'Could not load this locator.', 'cliq-store-locator' ) ) );
		return () => { live = false; };
	}, [ templateId, config.restUrl ] );

	// Load any Google Fonts the template grid + its bound designs (list_item /
	// infobox / search form) reference, so a font chosen in the builder actually
	// applies on the live site (the builder loads them in its preview the same way).
	useEffect( () => {
		const excludedFonts = config?.themeFontAlreadyLoaded && config?.themeFontFamily
			? [ config.themeFontFamily ]
			: [];
		if ( template ) ensureFonts( template, excludedFonts );
		Object.values( designs || {} ).forEach( ( d ) => {
			const m = parseModel( d?.model );
			if ( m ) ensureFonts( m, excludedFonts );
		} );
		// The Load More button's font lives in widget SETTINGS, not the layout-item
		// css collectFonts()/ensureFonts() scan above — load it separately.
		const loadMoreFont = findWidget( template, 'results' )?.loadMoreFontFamily;
		if ( loadMoreFont && ! excludedFonts.includes( loadMoreFont ) ) loadGoogleFont( loadMoreFont );
	}, [ template, designs, config?.themeFontAlreadyLoaded, config?.themeFontFamily ] );

	// A map can invite the visitor to share their location on load. The custom
	// prompt comes first; the browser permission request is only triggered by the
	// visitor pressing the confirmation button.
	const locationPromptShown = useRef( false );
	useEffect( () => {
		if ( mapSettings.askLocationOnLoad && ! locationPromptShown.current ) {
			locationPromptShown.current = true;
			setLocationPromptOpen( true );
		}
	}, [ mapSettings.askLocationOnLoad ] );

	// Optional coarse origin for useful initial distances before the visitor
	// shares an exact browser location. This is deliberately opt-in because the
	// browser contacts a third-party GeoIP service. Cache for this tab/session,
	// fail silently, and never replace coordinates supplied by the visitor.
	const ipFallbackAttempted = useRef( false );
	useEffect( () => {
		if ( ! mapSettings.ipLocationFallback || ipFallbackAttempted.current || query.location?.lat != null || query.location?.lng != null ) return undefined;
		ipFallbackAttempted.current = true;
		let storage = null;
		try { storage = window.sessionStorage; } catch ( error ) {}
		const cached = readCachedIpLocation( storage );
		if ( cached ) {
			setQuery( ( current ) => current.location ? current : { ...current, location: { ...cached, source: 'ip' } } );
			return undefined;
		}

		const controller = new AbortController();
		const timeout = window.setTimeout( () => controller.abort(), 5000 );
		fetch( 'https://get.geojs.io/v1/ip/geo.json', { signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer' } )
			.then( ( response ) => response.ok ? response.json() : Promise.reject( response ) )
			.then( ( data ) => {
				const location = normaliseIpLocation( data );
				if ( ! location ) return;
				cacheIpLocation( storage, location );
				setQuery( ( current ) => current.location ? current : { ...current, location: { ...location, source: 'ip' } } );
			} )
			.catch( () => {} )
			.finally( () => window.clearTimeout( timeout ) );
		return () => {
			window.clearTimeout( timeout );
			controller.abort();
		};
	}, [ mapSettings.ipLocationFallback, query.location?.lat, query.location?.lng ] );

	const requestLocation = useCallback( () => {
		setGeoError( '' );
		if ( ! navigator.geolocation ) {
			setLocationPromptOpen( false );
			setGeoError( __( 'Your browser or connection does not support location access.', 'cliq-store-locator' ) );
			return;
		}

		setLocating( true );
		navigator.geolocation.getCurrentPosition(
			( pos ) => {
				setGeoError( '' );
				setQuery( ( q ) => ( {
					...q,
					location: { lat: pos.coords.latitude, lng: pos.coords.longitude, source: 'geolocation' },
				} ) );
				setLocating( false );
				setLocationPromptOpen( false );
			},
			( err ) => {
				const messages = {
					1: __( 'Location access was denied. You can enable it in your browser 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 );
				}
				setLocating( false );
				setLocationPromptOpen( false );
			},
			{ timeout: 10000 }
		);
	}, [] );

	// Browser permission can be changed while the page remains open. Remove a
	// stale denial immediately when it becomes granted; successful coordinates
	// also clear it below for browsers without the Permissions API.
	useEffect( () => {
		if ( ! navigator.permissions?.query ) return undefined;
		let active = true;
		let permission;
		const syncPermission = () => {
			if ( active && permission?.state === 'granted' ) setGeoError( '' );
		};
		navigator.permissions.query( { name: 'geolocation' } ).then( ( result ) => {
			if ( ! active ) return;
			permission = result;
			syncPermission();
			permission.addEventListener?.( 'change', syncPermission );
		} ).catch( () => {} );
		return () => {
			active = false;
			permission?.removeEventListener?.( 'change', syncPermission );
		};
	}, [] );

	useEffect( () => {
		if ( query.location?.lat != null && query.location?.lng != null ) setGeoError( '' );
	}, [ query.location?.lat, query.location?.lng ] );

	useEffect( () => {
		if ( ! locationPromptOpen ) return undefined;
		const closeOnEscape = ( event ) => {
			if ( event.key === 'Escape' && ! locating ) setLocationPromptOpen( false );
		};
		document.addEventListener( 'keydown', closeOnEscape );
		return () => document.removeEventListener( 'keydown', closeOnEscape );
	}, [ locationPromptOpen, locating ] );

	// Fetch one page of results. Page 1 REPLACES the list (a new search);
	// later pages APPEND — the map re-renders from the grown array, so loaded
	// stores accumulate on it too.
	const pageRef = useRef( 1 );
	const requestRef = useRef( 0 );
	const fetchPage = useCallback( ( pageNo, append ) => {
		const requestId = ++requestRef.current;
		pageRef.current = pageNo;
		setLoading( true );
		setRefreshing( ! append );
		fetch( buildSearchUrl( config.restUrl, query, config.lang, pageNo, config.defaults || {}, { ipLocationLimitsRadius: !! mapSettings.ipLocationLimitsRadius } ) )
			.then( ( r ) => r.json() )
			.then( ( res ) => {
				// A stale slow response from a superseded page must not clobber
				// the current one (e.g. filter changed while page 3 was in flight).
				if ( requestRef.current !== requestId ) return;
				const rows = res?.results || res?.records || ( Array.isArray( res ) ? res : [] );
				const withUnit = rows.map( ( s ) => ( { ...s, distanceUnit: res?.unit || query.unit } ) );
				setResults( ( prev ) => ( append ? [ ...prev, ...withUnit ] : withUnit ) );
				setTotal( res?.total ?? withUnit.length );
				// Page-1 search carries the category facet for the whole match set;
				// "load more" (append) keeps the existing facet. Absent (no active
				// narrowing, or a category is selected) → null, so the browser
				// reverts to global counts.
				if ( ! append ) setCategoryCounts( res?.categoryCounts ?? null );
				setSearchError( false );
				// HTML marker designs resolved for THIS page's stores (dynamic —
				// not every store necessarily shares the designs already loaded).
				if ( res?.designs ) setDesigns( ( d ) => ( { ...d, ...res.designs } ) );
			} )
			.catch( () => {
				if ( requestRef.current !== requestId ) return;
				setSearchError( true );
			} )
			.finally( () => {
				if ( requestRef.current !== requestId ) return;
				setLoading( false );
				setRefreshing( false );
			} );
	}, [ config.restUrl, config.lang, config.defaults, query ] );

	// Search only after the template has loaded (and its query defaults applied),
	// so a single request fires on load instead of one for the initial state and
	// another after the template seeds radius/page-size/filters. When SSR
	// bootstrapped, the FIRST search is skipped — the results are already seeded —
	// but any later query change (form submit, filter, geolocation onload/ip)
	// changes fetchPage's identity, firing a fresh page-1 search.
	const skipFirstSearch = useRef( !! boot );
	// Category drill-down (tile click / "Back to categories") is a discrete,
	// one-shot action, unlike typing into a text field — debouncing it only
	// Category-chip changes fetch immediately, while continuous/typed filters
	// retain the short debounce. Existing cards stay mounted during either
	// request and ResultsWidget applies a quiet updating treatment; replacing
	// them with a temporary Loading message caused a visible layout flash.
	const prevCategoryRef = useRef( query.category );
	useEffect( () => {
		if ( ! template ) return undefined;
		if ( skipFirstSearch.current ) {
			skipFirstSearch.current = false;
			prevCategoryRef.current = query.category;
			return undefined;
		}
		if ( prevCategoryRef.current !== query.category ) {
			prevCategoryRef.current = query.category;
			fetchPage( 1, false );
			return undefined;
		}
		const t = setTimeout( () => fetchPage( 1, false ), 300 );
		return () => clearTimeout( t );
	}, [ fetchPage, template, query.category ] );

	// "Load more" (results widget): fetch the next page and append.
	const hasMore = results.length < total;
	const loadMore = useCallback( () => fetchPage( pageRef.current + 1, true ), [ fetchPage ] );
	useEffect( () => {
		if ( viewSeeded.current || ! template ) return;
		viewSeeded.current = true;
		const configured = ( findWidget( template, 'results' ) || {} ).viewDefault;
		if ( [ 'list', 'map' ].includes( configured ) ) setView( configured );
	}, [ template ] );

	const updateView = useCallback( ( next ) => {
		setMapTransitioned( true );
		setView( [ 'split', 'list', 'map' ].includes( next ) ? next : 'split' );
	}, [] );
	// Kept for the existing single "Show map only" toggle and any consumer that
	// still thinks in terms of a boolean.
	const updateMapOnly = useCallback( ( next ) => {
		updateView( next ? 'map' : 'split' );
	}, [ updateView ] );

	const ctx = useMemo( () => ( {
		query, setQuery, results, total, loading, refreshing, designs, config,
		selectedId, setSelectedId, setGeoError, loadMore, hasMore, categoryCounts,
		mapOnly, setMapOnly: updateMapOnly,
		view, setView: updateView, listOnly,
		locationPromptOpen, locating, requestLocation, setLocationPromptOpen,
		locationPromptSettings: mapSettings,
		// SSR-seeded data widgets consume instead of fetching (categories list,
		// search-form select options keyed by field id).
		bootstrapData: boot ? { categories: boot.categories, fieldOptions: boot.fieldOptions } : null,
	} ), [ query, results, total, loading, refreshing, designs, config, selectedId, loadMore, hasMore, categoryCounts, mapOnly, updateMapOnly, view, updateView, listOnly, locationPromptOpen, locating, requestLocation, mapSettings ] );

	if ( error ) return <div className="cliq-sl-error">{ error }</div>;
	if ( ! template ) return <div className="cliq-sl-loading">{ __( 'Loading…', 'cliq-store-locator' ) }</div>;
	const resultsSettings = findWidget( template, 'results' ) || {};
	const showListLabel = resultsSettings.mapToggleActiveLabel === undefined
		? __( 'Show list', 'cliq-store-locator' )
		: resultsSettings.mapToggleActiveLabel;

	return (
		<LocatorContext.Provider value={ ctx }>
			<div className={ `cliq-sl ${ layoutScope }${ mapOnly ? ' is-map-only' : '' }${ listOnly ? ' is-list-only' : '' }${ mapTransitioned ? ' has-map-transition' : '' }` }>
				{ layoutCss ? <style>{ layoutCss }</style> : null }
				{ resultsSettings.viewSwitcher ? (
					/* Segmented list | split | map control. Radio semantics, not
					   three buttons, so the current view is announced. */
					<div className="cliq-sl-view-switcher" role="radiogroup" aria-label={ __( 'Choose a view', 'cliq-store-locator' ) }>
						{ [
							[ 'list', resultsSettings.viewListLabel || __( 'List', 'cliq-store-locator' ), resultsSettings.viewListIcon || 'list' ],
							[ 'split', resultsSettings.viewSplitLabel || __( 'Split', 'cliq-store-locator' ), resultsSettings.viewSplitIcon || 'columns-2' ],
							[ 'map', resultsSettings.viewMapLabel || __( 'Map', 'cliq-store-locator' ), resultsSettings.viewMapIcon || 'map' ],
						].map( ( [ value, label, icon ] ) => (
							<button
								key={ value }
								type="button"
								role="radio"
								aria-checked={ view === value }
								className={ `cliq-sl-view-switcher__option${ view === value ? ' is-active' : '' }` }
								onClick={ () => updateView( value ) }
							>
								<WithIcon item={ { icon } }>{ label }</WithIcon>
							</button>
						) ) }
					</div>
				) : mapOnly ? (
					<button type="button" className="cliq-sl-map-only-toggle cliq-sl-map-view-toggle" title={ showListLabel || __( 'Show list', 'cliq-store-locator' ) } aria-label={ showListLabel || __( 'Show list', 'cliq-store-locator' ) } onClick={ () => updateMapOnly( false ) }>
						<WithIcon item={ { icon: resultsSettings.mapToggleActiveIcon || 'arrow-left' } }>{ showListLabel }</WithIcon>
					</button>
				) : null }
					{ searchError ? <div className="cliq-sl-search-error" role="alert">{ __( 'Search failed. Please try again.', 'cliq-store-locator' ) }</div> : null }
					{ ! searchError && geoError ? <div className="cliq-sl-search-error" role="alert">{ geoError }</div> : null }
				{ /* Same structural classes as any rendered design (design.scss
				 .cliq-widget-row/col/cell base grid) + the n-/cl- hooks
				 buildDesignCss() targets — reset → base → per-row/col/item
				 custom CSS, all via the one shared stylesheet, zero inline. */ }
				{ ( template.rows || [] ).map( ( row ) => (
					<div key={ row.id } className={ nodeClass( 'cliq-widget-row cliq-sl-row', row ) }>
						{ ( row.columns || [] ).map( ( col ) => {
							const containsMap = ( col.items || [] ).some( ( item ) => item.type === 'widget' && item.widget === 'map' );
							return <div key={ col.id } className={ nodeClass( `cliq-widget-col cliq-sl-col${ containsMap ? ' cliq-sl-col--map' : '' }`, col ) }>
								{ ( col.items || [] ).map( ( item ) => {
									const W = WIDGETS[ item.widget ];
									const isMap = item.type === 'widget' && item.widget === 'map';
									return (
										<div key={ item.id } className={ nodeClass( `cliq-widget-cell cliq-widget-cl-${ item.id } cliq-sl-cell${ isMap ? ' cliq-sl-cell--map' : '' }`, item ) }>
											{ W ? <W ws={ item.widgetSettings || {} } /> : null }
										</div>
									);
								} ) }
							</div>;
						} ) }
					</div>
				) ) }
			</div>
		</LocatorContext.Provider>
	);
};

export default LocatorApp;
