<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Frontend;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\REST\PublicController;

/**
 * True server-side render of a locator template: the full widget grid (rows /
 * columns / cells), a static search form, filters, count, the first page of
 * store cards, and an empty map wrapper — plus an inlined bootstrap JSON the
 * frontend app boots from without any REST fetch.
 *
 * Markup is a direct port of the React components (LocatorApp.js + widgets.js):
 * same class names, same inline styles, same scoped layout stylesheet with a
 * DETERMINISTIC scope (cliq-sl-l-t{id} / cliq-widget-d-f{id}) so the client
 * render swaps in structurally identical markup in a single frame. This is
 * deliberately replace-not-hydrate — parity is by class/structure, NOT byte-
 * exact (inline style property order may differ); do not add byte-diff tests.
 */
final class LocatorSsrRenderer
{
    private PublicController $api;
    private DesignRenderer $renderer;
    /** Renders 'category_tile' designs (category tokens, not store tokens) —
     * null on hosts/callers that don't pass one, in which case
     * renderCategoryBrowser() falls back to its hardcoded markup. */
    private ?DesignRenderer $categoryRenderer;
    /** @var array<string, mixed> */
    private array $settings;

    /** Lazily-resolved categories payload (the /categories body). */
    private ?array $categories = null;
    /** field id → resolved dynamic options, collected while rendering the form. */
    private array $fieldOptions = [];
    /** Whether any rendered widget consumes the categories payload client-side. */
    private bool $needsCategories = false;

    /** @param array<string, mixed> $settings SettingsService::all() */
    public function __construct(PublicController $api, DesignRenderer $renderer, array $settings, ?DesignRenderer $categoryRenderer = null)
    {
        $this->api = $api;
        $this->renderer = $renderer;
        $this->settings = $settings;
        $this->categoryRenderer = $categoryRenderer;
    }

    /**
     * Full inner HTML for `.cliq-sl-root`: the rendered grid followed by the
     * bootstrap `<script type="application/json">` (a DIRECT child of the root,
     * which is how the frontend finds it).
     *
     * @param array<string, mixed>  $payload  PublicController::templatePayload()
     * @param array<string, mixed>  $search   PublicController::browseResponse()
     * @param array<string, string> $defaults country|category|brand|q placement filters
     */
    public function render(int $templateId, array $payload, array $search, array $defaults = []): string
    {
        $model = is_array($payload['model'] ?? null) ? $payload['model'] : ['rows' => []];
        $designs = is_array($payload['designs'] ?? null) ? $payload['designs'] : [];

        // Seeds mirroring LocatorApp's initial query: radius/unit from the
        // distance widget, filters from the placement defaults.
        $dist = $this->firstWidgetSettings($model, 'distance');
        $ctx = [
            'designs' => $designs,
            'search' => $search,
            'defaults' => $defaults,
            'radius' => is_numeric($dist['default'] ?? null) ? $dist['default'] + 0 : 50,
            'unit' => (string) ($dist['unit'] ?? ($this->settings['distance_unit'] ?? 'mi')),
        ];

        $html = $this->renderGrid($templateId, $model, $ctx);

        $bootstrap = [
            'template' => $payload,
            'search' => $search,
        ];
        if ($this->needsCategories) {
            $bootstrap['categories'] = $this->categories();
        }
        if ($this->fieldOptions !== []) {
            $bootstrap['fieldOptions'] = $this->fieldOptions;
        }

        /**
         * Filter the SSR bootstrap payload inlined for the frontend app (template
         * model + designs, first-page search results, categories, field options).
         * Return an empty array to suppress the bootstrap (the app then boots in
         * lazy-load fashion over the static markup).
         *
         * @param array<string, mixed>  $bootstrap  The bootstrap payload.
         * @param int                   $templateId Locator template id.
         * @param array<string, string> $defaults   Placement default filters.
         */
        $bootstrap = apply_filters('cliq_sl_ssr_bootstrap', $bootstrap, $templateId, $defaults);
        if (! is_array($bootstrap) || $bootstrap === []) {
            return $html;
        }

        // Inline by necessity, not by preference: this is per-placement render
        // state that has to arrive with the markup it belongs to, so an enqueued
        // file cannot carry it. `type="application/json"` makes it a data island
        // the browser never executes — the enqueued bundle reads it back out.
        //
        // JSON_HEX_TAG is load-bearing: it encodes < and > (as </>) so
        // a store name/description containing "</script>" cannot break out of
        // the bootstrap element.
        $json = wp_json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE);

        return $html . '<script type="application/json" class="cliq-sl-bootstrap">' . $json . '</script>';
    }

    /**
     * Server render of the list | split | map switcher.
     *
     * Structural parity with LocatorApp's version — same classes, same radio
     * semantics — so the client swap is a no-op rather than a visible re-render.
     *
     * @param array<string, mixed> $ws results widget settings
     */
    private function renderViewSwitcher(array $ws, string $view): string
    {
        // Same defaults as LocatorApp's segments. The icon has to be here too:
        // rendering label-only server-side and adding the glyph on hydration
        // shifts every label sideways the moment the app boots.
        $options = [
            ['list', (string) ($ws['viewListLabel'] ?? '') !== '' ? (string) $ws['viewListLabel'] : __('List', 'cliq-store-locator'), (string) ($ws['viewListIcon'] ?? '') !== '' ? (string) $ws['viewListIcon'] : 'list'],
            ['split', (string) ($ws['viewSplitLabel'] ?? '') !== '' ? (string) $ws['viewSplitLabel'] : __('Split', 'cliq-store-locator'), (string) ($ws['viewSplitIcon'] ?? '') !== '' ? (string) $ws['viewSplitIcon'] : 'columns-2'],
            ['map', (string) ($ws['viewMapLabel'] ?? '') !== '' ? (string) $ws['viewMapLabel'] : __('Map', 'cliq-store-locator'), (string) ($ws['viewMapIcon'] ?? '') !== '' ? (string) $ws['viewMapIcon'] : 'map'],
        ];

        $buttons = '';
        foreach ($options as [$value, $label, $icon]) {
            $active = $value === $view;
            $glyph = DesignIcons::svg($icon, 15);
            $buttons .= sprintf(
                '<button type="button" role="radio" aria-checked="%s" class="%s">%s</button>',
                $active ? 'true' : 'false',
                esc_attr('cliq-sl-view-switcher__option' . ($active ? ' is-active' : '')),
                $glyph === '' ? esc_html($label) : $glyph . ' ' . esc_html($label)
            );
        }

        return '<div class="cliq-sl-view-switcher" role="radiogroup" aria-label="'
            . esc_attr__('Choose a view', 'cliq-store-locator') . '">' . $buttons . '</div>';
    }

    /* ---- template grid (port of LocatorApp.js render + layoutCss) ---------- */

    /**
     * The template grid, through the SAME shared pipeline as any rendered
     * design: structural classes from design.scss (.cliq-widget-row/col/cell)
     * + one scoped stylesheet from DesignRenderer::designCss() carrying the
     * container/row/column/item custom CSS and responsive spans — reset →
     * base → custom, zero inline styles. Mirror of LocatorApp.js's grid.
     *
     * @param array<string, mixed> $model @param array<string, mixed> $ctx
     */
    private function renderGrid(int $templateId, array $model, array $ctx): string
    {
        $scope = 'cliq-sl-l-t' . $templateId;

        // The view switcher and the initial view come from the results widget, so
        // the first paint already shows the right pane and the right active
        // segment — otherwise the control popped in after hydration and a
        // non-split default visibly snapped.
        $resultsWs = $this->firstWidgetSettings($model, 'results');
        $view = in_array((string) ($resultsWs['viewDefault'] ?? 'split'), ['split', 'list', 'map'], true)
            ? (string) ($resultsWs['viewDefault'] ?? 'split')
            : 'split';
        $viewClass = $view === 'map' ? ' is-map-only' : ($view === 'list' ? ' is-list-only' : '');

        $html = '<div class="cliq-sl ' . esc_attr($scope . $viewClass) . '">';
        if (! empty($resultsWs['viewSwitcher'])) {
            $html .= $this->renderViewSwitcher($resultsWs, $view);
        }
        // Grid CSS + widget-settings rules (results gap/columns, map height, …)
        // in ONE generated stylesheet — settings never touch element style attrs.
        $css = trim($this->renderer->designCss($model, '.' . $scope) . "\n" . $this->widgetSettingsCss($model, '.' . $scope));
        $this->renderer->enqueueCss($css);

        foreach ($this->rows($model) as $row) {
            $html .= '<div class="' . esc_attr($this->renderer->elementClass('cliq-widget-row cliq-sl-row', $row)) . '">';
            foreach ((is_array($row['columns'] ?? null) ? $row['columns'] : []) as $col) {
                $col = is_array($col) ? $col : [];
                $items = is_array($col['items'] ?? null) ? $col['items'] : [];
                $containsMap = false;
                foreach ($items as $candidate) {
                    if (is_array($candidate) && ($candidate['type'] ?? '') === 'widget' && ($candidate['widget'] ?? '') === 'map') {
                        $containsMap = true;
                        break;
                    }
                }
                $html .= '<div class="' . esc_attr($this->renderer->elementClass('cliq-widget-col cliq-sl-col' . ($containsMap ? ' cliq-sl-col--map' : ''), $col)) . '">';
                foreach ($items as $item) {
                    $item = is_array($item) ? $item : [];
                    $isMap = ($item['type'] ?? '') === 'widget' && ($item['widget'] ?? '') === 'map';
                    $html .= '<div class="' . esc_attr($this->renderer->elementClass('cliq-widget-cell cliq-widget-cl-' . $this->nodeId($item) . ' cliq-sl-cell' . ($isMap ? ' cliq-sl-cell--map' : ''), $item)) . '">'
                        . $this->renderWidget($item, $ctx)
                        . '</div>';
                }
                $html .= '</div>';
            }
            $html .= '</div>';
        }

        return $html . '</div>';
    }

    /* ---- widget dispatch (port of widgets.js WIDGETS map) ------------------ */

    /** @param array<string, mixed> $item @param array<string, mixed> $ctx */
    private function renderWidget(array $item, array $ctx): string
    {
        if (($item['type'] ?? '') !== 'widget') {
            return '';
        }
        $ws = is_array($item['widgetSettings'] ?? null) ? $item['widgetSettings'] : [];

        switch ((string) ($item['widget'] ?? '')) {
            case 'search':
                return $this->renderSearchForm($ws, $ctx);
            case 'distance':
                return $this->renderDistance($ws, $ctx);
            case 'category':
                return $this->renderCategoryFilter($ws, $ctx);
            case 'brand':
                return $this->renderBrandFilter($ws, $ctx);
            case 'count':
                return $this->renderCount($ws, $ctx);
            case 'results':
                return $this->renderResults($ws, $ctx);
            case 'map':
                return $this->renderMap($ws, $ctx);
            default:
                return '';
        }
    }

    /* ---- search form (port of widgets.js SearchForm) ------------------------ */

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderSearchForm(array $ws, array $ctx): string
    {
        $formId = (int) ($ws['searchFormId'] ?? 0);
        $model = $this->designModel($ctx['designs'], $formId);
        if ($model === null) {
            return '';
        }

        $scope = 'cliq-widget-d-f' . $formId;
        $css = $this->renderer->designCss($model, '.' . $scope);

        $html = '<div class="cliq-sl-w cliq-sl-w--search">'
            . '<form class="cliq-widget cliq-widget-design cliq-widget-design--form cliq-sl-searchform ' . esc_attr($scope) . '">';
        $this->renderer->enqueueCss($css);

        // Search and location buttons are both builder fields. Neither is
        // auto-appended: their own field label/settings are authoritative.
        foreach ($this->rows($model) as $row) {
            $html .= '<div class="' . esc_attr($this->renderer->elementClass('cliq-widget-row', $row)) . '">';
            foreach ((is_array($row['columns'] ?? null) ? $row['columns'] : []) as $col) {
                $col = is_array($col) ? $col : [];
                $html .= '<div class="' . esc_attr($this->renderer->elementClass('cliq-widget-col', $col)) . '">';
                foreach ((is_array($col['items'] ?? null) ? $col['items'] : []) as $f) {
                    $f = is_array($f) ? $f : [];
                    $html .= '<div class="cliq-widget cliq-widget-cell cliq-widget-cl-' . esc_attr($this->nodeId($f)) . '">'
                        . $this->renderFormField($f, $ctx)
                        . '</div>';
                }
                $html .= '</div>';
            }
            $html .= '</div>';
        }

        return $html . '</form></div>';
    }

    /** One static form field (port of SearchForm's renderField). */
    private function renderFormField(array $f, array $ctx): string
    {
        $type = (string) ($f['type'] ?? '');
        $label = (string) ($f['label'] ?? '');

        if ($type === 'submit') {
            return '<button type="submit" class="' . esc_attr($this->renderer->elementClass('cliq-sl-search__btn', $f)) . '">'
                . $this->renderer->iconContent($f, esc_html($label !== '' ? $label : __('Search', 'cliq-store-locator'))) . '</button>';
        }
        if ($type === 'geocode') {
            $gl = $label !== '' ? $label : __('Use my location', 'cliq-store-locator');
            $body = (! empty($f['icon']) || $label !== '')
                ? $this->renderer->iconContent($f, esc_html($label))
                : '<span aria-hidden="true">◎</span>';
            return '<button type="button" class="' . esc_attr($this->renderer->elementClass('cliq-sl-search__geo', $f)) . '"'
                . ' title="' . esc_attr($gl) . '" aria-label="' . esc_attr($gl) . '">' . $body . '</button>';
        }
        // Self-configuring fields — same shape as submit/geocode above: they
        // write straight to the app's query state (openNow/sort), never the
        // generic mapTo/meta-filter pipeline, so they're excluded from the
        // input-types allowlist below and rendered directly. $f doubles as the
        // "widget settings" bag these two render methods expect (label/
        // byDistance/byName) — same shape the retired standalone Open-now/Sort
        // widgets passed them.
        if ($type === 'open_now') {
            return $this->renderOpenToggle($f);
        }
        if ($type === 'sort_by') {
            return $this->renderSort($f);
        }

        // Non-input layout items aren't rendered by the live form either.
        $inputTypes = ['text', 'email', 'tel', 'number', 'textarea', 'select', 'chips', 'accordion', 'popup', 'options_list', 'checkbox', 'radio', 'range', 'toggle', 'date'];
        if (! in_array($type, $inputTypes, true)) {
            return '';
        }

        // Seed a distance-mapped field from the locator's initial radius (the JS
        // seeds vals the same way post-mount, so SSR shows the real value).
        $seed = ($f['mapTo'] ?? '') === 'distance' ? (string) $ctx['radius'] : '';

        // hideLabel (per-field builder toggle) hides the visible label only.
        $labelHtml = ($label !== '' && empty($f['hideLabel'])) ? '<span class="cliq-sl-w__label">' . esc_html($label) . '</span>' : '';
        $fieldId = preg_replace('/[^a-z0-9_-]/i', '', (string) ($f['id'] ?? ''));
        $fluidControl = in_array($type, ['textarea', 'chips', 'accordion', 'options_list', 'checkbox', 'radio'], true);
        $wrap = static fn (string $inner): string => '<label class="cliq-sl-ff">' . $labelHtml
            . '<span class="cliq-sl-ff__control' . ($fluidControl ? ' is-fluid-control' : '')
            . ' cliq-sl-field-shell-' . esc_attr($fieldId) . '">' . $inner . '</span></label>';

        // Every control carries its per-item css hook (elementClass — the SSR
        // twin of the frontend's nodeClass on the same element), so the 'input'
        // default look / any admin overrides in the form's stylesheet apply to
        // the SSR paint exactly as they do after the client swap.
        if (in_array($type, ['select', 'chips', 'accordion', 'popup', 'options_list'], true)) {
            // The native select carries the n-hook so the pre-swap SSR paint
            // matches the live form, where Glide adds the same hook to its own
            // control element at init (widgets.js GlideSelect).
            $multiple = ! empty($f['multiple']) ? ' multiple' : '';
            $options = '<option value="">' . esc_html((string) ($f['placeholder'] ?? '') !== '' ? (string) $f['placeholder'] : __('Any', 'cliq-store-locator')) . '</option>';
            foreach ($this->fieldChoices($f) as $o) {
                $value = (string) (is_array($o) ? ($o['value'] ?? '') : $o);
                $optLabel = (string) (is_array($o) ? ($o['label'] ?? $value) : $o);
                $selected = ($seed !== '' && $value === $seed) ? ' selected' : '';
                $options .= '<option value="' . esc_attr($value) . '"' . $selected . '>' . esc_html($optLabel) . '</option>';
            }
            return $wrap(
                '<div class="cliq-sl-glidewrap"><select class="'
                . esc_attr($this->renderer->elementClass('', $f))
                . '"'
                . $multiple
                . '>'
                . $options
                . '</select></div>'
            );
        }
        if ($type === 'radio' || $type === 'checkbox') {
            $key = (string) (($f['name'] ?? '') !== '' ? $f['name'] : ($f['id'] ?? ''));
            $inner = '<span class="' . esc_attr($this->renderer->elementClass('cliq-sl-choices', $f)) . '">';
            foreach ($this->fieldChoices($f) as $o) {
                $value = (string) (is_array($o) ? ($o['value'] ?? '') : $o);
                $optLabel = (string) (is_array($o) ? ($o['label'] ?? $value) : $o);
                $inner .= '<label class="cliq-sl-choice">'
                    . '<input type="' . esc_attr($type) . '" name="' . esc_attr($key) . '" value="' . esc_attr($value) . '" class="cliq-sl-choice__input">'
                    . '<span class="cliq-sl-choice__box cliq-sl-choice__box--' . esc_attr($type) . '"></span>'
                    . '<span class="cliq-sl-choice__label">' . esc_html($optLabel) . '</span></label>';
            }
            return $wrap($inner . '</span>');
        }
        if ($type === 'toggle') {
			return $wrap($this->renderSwitchControl(
				$f,
				$label !== '' ? $label : __('Toggle', 'cliq-store-locator')
			));
        }
        if ($type === 'range') {
            $min = is_numeric($f['rangeMin'] ?? null) ? $f['rangeMin'] + 0 : 0;
            $max = is_numeric($f['rangeMax'] ?? null) ? $f['rangeMax'] + 0 : 100;
            $step = is_numeric($f['rangeStep'] ?? null) ? $f['rangeStep'] + 0 : 1;
            $value = $seed !== '' ? $seed : (string) $min;
            return $wrap(
                '<span class="' . esc_attr($this->renderer->elementClass('cliq-sl-rangewrap', $f)) . '">'
                . '<input type="range" min="' . esc_attr((string) $min) . '" max="' . esc_attr((string) $max) . '"'
                . ' step="' . esc_attr((string) $step) . '" value="' . esc_attr($value) . '">'
                . '<span class="cliq-sl-rangeval">'
                . esc_html((static function (string $u) use ($value): string {
                    return $u !== '' ? $value . ' ' . $u : $value;
                })(trim((string) ($f['unit'] ?? '')) ?: $this->siteUnit()))
                . '</span></span>'
            );
        }

        // text/email/tel/number/textarea/date — the live form renders these all as
        // a plain input (number keeps its type); mirror that exactly, including
        // the location-autocomplete variant's class.
        $isLocation = ($f['mapTo'] ?? '') === 'location';
        $textBase = ($isLocation && (! empty($f['autocomplete']) || ! empty($f['showUseMyLocation'])))
            ? 'cliq-sl-location-input'
            : 'cliq-sl-textinput';
        $input = '<input type="' . ($type === 'number' ? 'number' : 'text') . '" class="' . esc_attr($this->renderer->elementClass($textBase, $f)) . '"'
            . ' value="' . esc_attr($seed) . '" placeholder="' . esc_attr((string) ($f['placeholder'] ?? '')) . '">';
        if ($isLocation && ! empty($f['showUseMyLocation'])) {
            $input = '<span class="cliq-sl-location-control">' . $input
                . '<button type="button" class="cliq-sl-location-action" title="' . esc_attr__('Use my location', 'cliq-store-locator') . '" aria-label="' . esc_attr__('Use my location', 'cliq-store-locator') . '">'
                . '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="7"></circle><circle cx="12" cy="12" r="2"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path></svg></button></span>';
        }

		return $wrap($input);
    }

    /**
     * A field's choices, resolving dynamic sources server-side and recording them
     * for the bootstrap (so the app mounts its selects pre-populated). Mirrors
     * choiceSourceOf() + rangeOptions() + the /field-options fetch in widgets.js.
     *
     * @return array<int, mixed>
     */
    /** The site's measurement unit, used whenever a field declares none. */
    private function siteUnit(): string
    {
        return strtolower((string) ($this->settings['distance_unit'] ?? 'mi')) === 'km' ? 'km' : 'mi';
    }

    private function fieldChoices(array $f): array
    {
        $source = (string) ($f['choicesSource'] ?? '');
        if ($source === '') {
            $mapTo = (string) ($f['mapTo'] ?? '');
            $source = $mapTo === 'category'
                ? 'categories'
                : ($mapTo === 'brand' ? 'brands' : ($mapTo === 'country' ? 'countries' : 'static'));
        }

        if ($source === 'static') {
            return is_array($f['options'] ?? null) ? $f['options'] : [];
        }
        if ($source === 'range') {
            $min = is_numeric($f['rangeMin'] ?? null) ? $f['rangeMin'] + 0 : 0;
            $max = is_numeric($f['rangeMax'] ?? null) ? $f['rangeMax'] + 0 : 0;
            $step = abs(is_numeric($f['rangeStep'] ?? null) ? $f['rangeStep'] + 0 : 1);
            $step = $step > 0 ? $step : 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, so
            // a saved layout cannot hard-code "km" onto a miles site and label
            // options with a unit the search does not actually use.
            $unit = trim((string) ($f['unit'] ?? '')) ?: $this->siteUnit();
            $out = [];
            for ($v = $min; $v <= $max && count($out) < 1000; $v += $step) {
                $s = (string) (is_int($v) || (is_float($v) && floor($v) === $v) ? (int) $v : round($v * 100) / 100);
                $out[] = ['value' => $s, 'label' => $unit !== '' ? $s . ' ' . $unit : $s];
            }
            return $out;
        }

        $key = '';
        $scope = '';
        if ($source === 'meta') {
            $mapTo = (string) ($f['mapTo'] ?? '');
            $key = (string) ($f['choicesMetaKey'] ?? '');
            if ($key === '' && strpos($mapTo, 'meta:') === 0) {
                $key = substr($mapTo, 5);
            }
            if ($key === '') {
                return [];
            }
        } elseif ($source === 'store_field') {
            $key = (string) ($f['choicesStoreField'] ?? '');
            if ($key === '') {
                return [];
            }
            // A city/state list on a single-country locator should not offer
            // every city in the database.
            $scope = (string) ($f['choicesCountry'] ?? '');
        } elseif ($source === 'categories') {
            // Optional parent id: scopes the facet to one branch of the category
            // tree so a placement offers only the categories that apply to it.
            $parent = (int) ($f['choicesParent'] ?? 0);
            $key = $parent > 0 ? (string) $parent : '';
        }

        $options = $this->api->optionsForSource($source, sanitize_key($key), sanitize_key($scope ?? ''));
        $fieldId = (string) ($f['id'] ?? '');
        if ($fieldId !== '') {
            // The live SearchForm keys its options map by field id.
            $this->fieldOptions[$fieldId] = $options;
        }

        return $options;
    }

    /* ---- simple widgets (ports of widgets.js) ------------------------------- */

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderDistance(array $ws, array $ctx): string
    {
        $unit = (string) ($ws['unit'] ?? 'km');
        $min = is_numeric($ws['min'] ?? null) ? $ws['min'] + 0 : 5;
        $max = is_numeric($ws['max'] ?? null) ? $ws['max'] + 0 : 200;
        $step = is_numeric($ws['step'] ?? null) ? $ws['step'] + 0 : 5;
        $step = $step > 0 ? $step : 5;
        $radius = $ctx['radius'];

        $html = '<div class="cliq-sl-w cliq-sl-w--distance cliq-sl-distance">';
        if (! empty($ws['label'])) {
            $html .= '<label class="cliq-sl-w__label">' . esc_html((string) $ws['label']) . '</label>';
        }
        if (($ws['control'] ?? '') === 'slider') {
            $html .= '<div class="cliq-sl-distance__slider">'
                . '<input type="range" min="' . esc_attr((string) $min) . '" max="' . esc_attr((string) $max) . '"'
                . ' step="' . esc_attr((string) $step) . '" value="' . esc_attr((string) $radius) . '">'
                . '<span>' . esc_html($radius . ' ' . $unit) . '</span></div>';
        } else {
            $html .= '<select>';
            for ($r = $min; $r <= $max; $r += $step) {
                $selected = ((string) $r === (string) $radius) ? ' selected' : '';
                $html .= '<option value="' . esc_attr((string) $r) . '"' . $selected . '>' . esc_html($r . ' ' . $unit) . '</option>';
            }
            $html .= '</select>';
        }

        return $html . '</div>';
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderCategoryFilter(array $ws, array $ctx): string
    {
        $this->needsCategories = true;
        $cats = $this->categories();
        $current = (string) ($ctx['defaults']['category'] ?? '');
        $allLabel = (string) ($ws['allLabel'] ?? '') !== ''
            ? (string) $ws['allLabel']
            : __('All Categories', 'cliq-store-locator');

        // Tiles: category-tile designs that filter a separate results widget
        // (flat, single-select). Mirrors the live CategoryWidget tiles branch.
        if (($ws['display'] ?? '') === 'tiles') {
            $tileDesignId = (int) ($ws['categoryTileDesignId'] ?? 0);
            $catModel = $tileDesignId !== 0 && $this->categoryRenderer !== null
                ? $this->designModel($ctx['designs'], $tileDesignId)
                : null;
            if ($catModel === null) {
                return '<div class="cliq-sl-w cliq-sl-w--category cliq-sl-results--empty">' . esc_html__('No category tile design selected.', 'cliq-store-locator') . '</div>';
            }
            $tiles = $this->categoryRenderer->renderList($catModel, $cats, [
                'scope' => 'cliq-widget-d-cf' . $tileDesignId,
                'wrapperClass' => static function (array $c) use ($current): string {
                    $active = ($current !== '' && (string) ($c['id'] ?? '') === $current) ? ' is-active' : '';
                    return 'cliq-sl-cat cliq-sl-cat-c' . (int) ($c['id'] ?? 0) . $active;
                },
                'autoColorVar' => static fn (array $c) => $c['color'] ?? null,
            ]);
            $label = ! empty($ws['label'])
                ? '<span class="cliq-sl-w__label">' . esc_html((string) $ws['label']) . '</span>'
                : '';

            return '<div class="cliq-sl-w cliq-sl-w--category cliq-sl-cattiles">' . $label . '<div class="cliq-sl-catgrid">' . $tiles . '</div></div>';
        }

        if (($ws['display'] ?? '') === 'dropdown') {
            $html = '<div class="cliq-sl-w cliq-sl-w--category cliq-sl-filter">';
            if (! empty($ws['label'])) {
                $html .= '<label class="cliq-sl-w__label">' . esc_html((string) $ws['label']) . '</label>';
            }
            $html .= '<select><option value="">' . esc_html($allLabel) . '</option>';
            foreach ($cats as $c) {
                $selected = ($current !== '' && (string) $c['id'] === $current) ? ' selected' : '';
                $html .= '<option value="' . esc_attr((string) $c['id']) . '"' . $selected . '>' . esc_html((string) $c['name']) . '</option>';
            }
            return $html . '</select></div>';
        }

        $html = '<div class="cliq-sl-w cliq-sl-w--category cliq-sl-chips">'
            . '<button type="button" class="cliq-sl-chip' . ($current === '' ? ' is-active' : '') . '">' . esc_html($allLabel) . '</button>';
        foreach ($cats as $c) {
            $active = ($current !== '' && (string) $c['id'] === $current) ? ' is-active' : '';
            $html .= '<button type="button" class="cliq-sl-chip' . $active . '">' . esc_html((string) $c['name']) . '</button>';
        }

        return $html . '</div>';
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderBrandFilter(array $ws, array $ctx): string
    {
        $allLabel = (string) ($ws['allLabel'] ?? '') !== ''
            ? (string) $ws['allLabel']
            : __('All Brands', 'cliq-store-locator');
        $html = '<div class="cliq-sl-w cliq-sl-w--brand cliq-sl-filter">';
        if (! empty($ws['label'])) {
            $html .= '<label class="cliq-sl-w__label">' . esc_html((string) $ws['label']) . '</label>';
        }

        // Mirrors the live BrandWidget: a dropdown with only the "all" option
        // (no public brands endpoint yet).
        return $html . '<select><option value="">' . esc_html($allLabel) . '</option></select></div>';
    }

    /** @param array<string, mixed> $ws */
    private function renderOpenToggle(array $ws): string
    {
        $label = (string) ($ws['label'] ?? '') !== '' ? (string) $ws['label'] : __('Open now', 'cliq-store-locator');
		$labelHtml = empty($ws['hideLabel'])
			? '<span class="cliq-sl-w__label">' . esc_html($label) . '</span>'
			: '';

		$fieldId = preg_replace('/[^a-z0-9_-]/i', '', (string) ($ws['id'] ?? ''));
		return '<label class="cliq-sl-ff cliq-sl-open-now-field">' . $labelHtml
			. '<span class="cliq-sl-ff__control cliq-sl-field-shell-' . esc_attr($fieldId) . '">' . $this->renderSwitchControl($ws, $label)
			. '</span></label>';
    }

	/** @param array<string, mixed> $field */
	private function renderSwitchControl(array $field, string $ariaLabel): string
	{
		$onLabel = (string) ($field['toggleOnLabel'] ?? '');
		$offLabel = (string) ($field['toggleOffLabel'] ?? '');
		$hasInlineLabel = $onLabel !== '' || $offLabel !== '';
		$css = is_array($field['css'] ?? null) ? $field['css'] : [];
		$size = in_array(($css['toggleSize'] ?? ''), ['small', 'medium', 'large'], true)
			? (string) $css['toggleSize']
			: '';
		$classes = ['cliq-sl-toggle'];
		if ($hasInlineLabel) {
			$classes[] = 'has-inline-label';
		}
		if ($size !== '') {
			$classes[] = 'is-size-' . $size;
		}

		$html = '<span class="' . esc_attr($this->renderer->elementClass(implode(' ', $classes), $field)) . '">'
			. '<input type="checkbox" class="cliq-sl-toggle__input" aria-label="' . esc_attr($ariaLabel) . '">'
			. '<span class="cliq-sl-toggle__track">';
		if ($offLabel !== '') {
			$html .= '<span class="cliq-sl-toggle__inline cliq-sl-toggle__inline--off">' . esc_html($offLabel) . '</span>';
		}
		if ($onLabel !== '') {
			$html .= '<span class="cliq-sl-toggle__inline cliq-sl-toggle__inline--on">' . esc_html($onLabel) . '</span>';
		}
		$html .= '<span class="cliq-sl-toggle__thumb"></span></span>';

		return $html . '</span>';
	}

    /** @param array<string, mixed> $ws */
    private function renderSort(array $ws): string
    {
        $label = (string) ($ws['label'] ?? '');
        $labelHtml = $label !== '' && empty($ws['hideLabel'])
            ? '<span class="cliq-sl-w__label">' . esc_html($label) . '</span>'
            : '';
        $fieldId = preg_replace('/[^a-z0-9_-]/i', '', (string) ($ws['id'] ?? ''));
        $html = '<label class="cliq-sl-ff">' . $labelHtml
            . '<span class="cliq-sl-ff__control cliq-sl-field-shell-' . esc_attr($fieldId) . '"><select>';
        if (($ws['byDistance'] ?? true) !== false) {
            $html .= '<option value="distance">' . esc_html__('Distance', 'cliq-store-locator') . '</option>';
        }
        if (($ws['byName'] ?? true) !== false) {
            $html .= '<option value="name">' . esc_html__('Name', 'cliq-store-locator') . '</option>';
        }

        return $html . '</select></span></label>';
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderCount(array $ws, array $ctx): string
    {
        $label = (string) ($ws['label'] ?? '') !== '' ? (string) $ws['label'] : __('Number of shops', 'cliq-store-locator');
        $html = '<div class="cliq-sl-w cliq-sl-w--count cliq-sl-count">'
            . '<strong>' . esc_html($label . ': ' . (int) ($ctx['search']['total'] ?? 0)) . '</strong>';
        if (! empty($ws['showPrint'])) {
            $html .= '<button type="button" class="cliq-sl-count__print">⎙ ' . esc_html__('Print', 'cliq-store-locator') . '</button>';
        }

        return $html . '</div>';
    }

    /* ---- results (port of widgets.js ResultsWidget) ------------------------- */

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderResults(array $ws, array $ctx): string
    {
        $mode = (string) ($ws['displayMode'] ?? 'list');
        if ($mode === 'accordion') {
            $inner = $this->renderAccordionList($ws, $ctx);
        } elseif ($mode === 'grouped') {
            $inner = $this->renderGroupedAccordionList($ws, $ctx);
        } elseif ($mode === 'categories') {
            $inner = $this->renderCategoryBrowser($ws, $ctx);
        } else {
            $inner = $this->renderStoreList($ws, $ctx);
        }

        if (! empty($ws['showResultCount'])) {
            $label = trim((string) ($ws['resultCountLabel'] ?? '')) !== ''
                ? (string) $ws['resultCountLabel']
                : __('Number of shops', 'cliq-store-locator');
            $count = '<div class="cliq-sl-results-count cliq-sl-count">'
                . '<strong>' . esc_html($label . ': ' . (int) ($ctx['search']['total'] ?? 0)) . '</strong>';
            if (! empty($ws['resultCountShowPrint'])) {
                $count .= '<button type="button" class="cliq-sl-count__print">⎙ ' . esc_html__('Print', 'cliq-store-locator') . '</button>';
            }
            $inner = $count . '</div>' . $inner;
        }

        // Pagination twin of ResultsWidget's Load More: rendered whenever more
        // stores exist beyond the SSR'd first page. Inert until the app hydrates
        // (clicking pre-hydration does nothing; the React swap wires it up).
        // Lazy-load mode renders nothing pre-hydration (its sentinel has no
        // meaningful inert state) — the client swap adds it once mounted.
        $total = (int) ($ctx['search']['total'] ?? 0);
        $shown = is_array($ctx['search']['results'] ?? null) ? count($ctx['search']['results']) : 0;
        $showLoadMore = ($ws['showLoadMore'] ?? true) !== false;
        $loadMoreType = (string) ($ws['loadMoreType'] ?? 'button');
        if ($total > $shown && $shown > 0 && $showLoadMore && $loadMoreType !== 'lazy') {
            $label = trim((string) ($ws['loadMoreLabel'] ?? '')) !== ''
                ? (string) $ws['loadMoreLabel']
                : __('Load more', 'cliq-store-locator');
            $inner .= '<button type="button" class="cliq-sl-loadmore">' . $this->withIconMarkup($ws, esc_html($label)) . '</button>';
        }

        return '<div class="cliq-sl-w cliq-sl-w--results">' . $inner . '</div>';
    }

    /** Prepend/append the Load More button's icon to its label (PHP twin of
     * the JS WithIcon component). @param array<string, mixed> $ws */
    private function withIconMarkup(array $ws, string $content): string
    {
        $name = (string) ($ws['loadMoreIcon'] ?? '');
        if ($name === '') {
            return $content;
        }
        $glyph = DesignIcons::svg($name, 15);

        return ($ws['loadMoreIconPosition'] ?? 'right') === 'left'
            ? $glyph . ' ' . $content
            : $content . ' ' . $glyph;
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderStoreList(array $ws, array $ctx): string
    {
        $model = $this->designModel($ctx['designs'], (int) ($ws['listItemDesignId'] ?? 0));
        if ($model === null) {
            return '<div class="cliq-sl-results cliq-sl-results--empty">' . esc_html__('No result design selected.', 'cliq-store-locator') . '</div>';
        }
        $stores = is_array($ctx['search']['results'] ?? null) ? $ctx['search']['results'] : [];
        if ($stores === []) {
            return $this->emptyResults($ws);
        }

        $cards = $this->renderer->renderList($model, $stores, [
            'scope' => 'cliq-widget-d-r' . (int) ($ws['listItemDesignId'] ?? 0),
            'interactive' => true,
        ]);
        if ($cards === '') {
            return $this->emptyResults($ws);
        }

        return '<div' . $this->resultsContainerClassAttr($ws, 'cliq-sl-results') . '>' . $cards . '</div>';
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderAccordionList(array $ws, array $ctx): string
    {
        $model = $this->designModel($ctx['designs'], (int) ($ws['listItemDesignId'] ?? 0));
        if ($model === null) {
            return '<div class="cliq-sl-results cliq-sl-results--empty">' . esc_html__('No card design selected.', 'cliq-store-locator') . '</div>';
        }
        $stores = is_array($ctx['search']['results'] ?? null) ? $ctx['search']['results'] : [];
        if ($stores === []) {
            return $this->emptyResults($ws);
        }

        $cards = $this->renderer->renderList($model, $stores, [
            'scope' => 'cliq-widget-d-r' . (int) ($ws['listItemDesignId'] ?? 0),
            'accordion' => true,
        ]);

        // Accordion is always a column list — same container contract as the
        // plain results list (classes + variables), only the gap default differs.
        $accWs = array_merge($ws, ['layout' => 'list', 'direction' => 'column', 'gap' => is_numeric($ws['gap'] ?? null) ? $ws['gap'] : 12]);

        return '<div' . $this->resultsContainerClassAttr($accWs, 'cliq-sl-acc-list') . '>' . $cards . '</div>';
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderGroupedAccordionList(array $ws, array $ctx): string
    {
        $model = $this->designModel($ctx['designs'], (int) ($ws['listItemDesignId'] ?? 0));
        if ($model === null) {
            return '<div class="cliq-sl-results cliq-sl-results--empty">' . esc_html__('No result design selected.', 'cliq-store-locator') . '</div>';
        }
        $stores = is_array($ctx['search']['results'] ?? null) ? $ctx['search']['results'] : [];
        if ($stores === []) {
            return $this->emptyResults($ws);
        }

        $groupBy = in_array(($ws['groupBy'] ?? ''), ['category', 'brand', 'city', 'state', 'country'], true)
            ? (string) $ws['groupBy']
            : 'category';
        $groups = [];
        foreach ($stores as $store) {
            $store = is_array($store) ? $store : [];
            if ($groupBy === 'category') {
                $label = (string) ($store['categories'][0]['name'] ?? '');
            } elseif ($groupBy === 'brand') {
                $label = is_array($store['brand'] ?? null)
                    ? (string) ($store['brand']['name'] ?? '')
                    : (string) ($store['brand'] ?? '');
            } elseif ($groupBy === 'state') {
                $label = (string) ($store['location']['state'] ?? '');
            } elseif ($groupBy === 'city') {
                $label = (string) ($store['city'] ?? ($store['location']['city'] ?? ''));
            } else {
                $label = (string) ($store['country'] ?? ($store['location']['country'] ?? ''));
            }
            $label = trim($label) !== '' ? $label : __('Other', 'cliq-store-locator');
            $groups[$label][] = $store;
        }
        uksort($groups, 'strnatcasecmp');

        $html = '';
        $closedIcon = array_key_exists('groupClosedIcon', $ws) ? (string) $ws['groupClosedIcon'] : 'chevron-right';
        $iconMarkup = $closedIcon !== '' ? DesignIcons::svg($closedIcon, 16) : '';
        foreach ($groups as $label => $groupStores) {
            $cards = $this->renderer->renderList($model, $groupStores, [
                'scope' => 'cliq-widget-d-r' . (int) ($ws['listItemDesignId'] ?? 0),
                'interactive' => true,
            ]);
            $html .= '<section class="cliq-sl-group">'
                . '<button type="button" class="cliq-sl-group__toggle" aria-expanded="false">'
                . '<span>' . esc_html((string) $label) . '</span>'
                . (($ws['groupShowCount'] ?? true) !== false ? '<span class="cliq-sl-group__count">' . count($groupStores) . '</span>' : '')
                . ($iconMarkup !== '' ? '<span class="cliq-sl-group__chev" aria-hidden="true">' . $iconMarkup . '</span>' : '')
                . '</button><div class="cliq-sl-group__reveal"><div class="cliq-sl-group__body">'
                . $cards
                . '</div></div></section>';
        }

        $groupWs = array_merge($ws, ['layout' => 'list', 'direction' => 'column']);

        return '<div' . $this->resultsContainerClassAttr($groupWs, 'cliq-sl-group-list') . '>' . $html . '</div>';
    }

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderCategoryBrowser(array $ws, array $ctx): string
    {
        $this->needsCategories = true;
        $cats = $this->categories();
        // Show only the categories this placement actually contains. The counts
        // ride along with the same browse response the list uses, and the
        // hydrated app filters identically (widgets.js `shown`), so the server
        // paint matches instead of flashing categories that vanish on hydration.
        $counts = $ctx['search']['categoryCounts'] ?? null;
        if (is_array($counts) || is_object($counts)) {
            $counts = (array) $counts;
            $cats = array_values(array_filter(
                $cats,
                static fn (array $c): bool => (int) ($counts[(string) ($c['id'] ?? '')] ?? 0) > 0
            ));
        }
        $current = (string) ($ctx['defaults']['category'] ?? '');

        // A default category filter → the drill view (its stores + a Back link).
        if ($current !== '') {
            $name = '';
            foreach ($cats as $c) {
                if ((string) $c['id'] === $current) {
                    $name = (string) $c['name'];
                    break;
                }
            }
            return '<div class="cliq-sl-catdrill">'
                . '<button type="button" class="cliq-sl-back">'
                . esc_html('‹ ' . __('Back to categories', 'cliq-store-locator') . ($name !== '' ? ' · ' . $name : ''))
                . '</button>'
                . $this->renderStoreList($ws, $ctx)
                . '</div>';
        }

        // Every tile is fully design-driven — no hardcoded circle/name/count
        // markup. Per-category colour is opt-in now (the design's "Automatic"
        // background option, Colour section), not baked into a fixed look.
        $categoryTileDesignId = (int) ($ws['categoryTileDesignId'] ?? 0);
        $catModel = $categoryTileDesignId !== 0 && $this->categoryRenderer !== null
            ? $this->designModel($ctx['designs'], $categoryTileDesignId)
            : null;

        if ($catModel === null) {
            return '<div class="cliq-sl-results cliq-sl-results--empty">' . esc_html__('No category tile design selected.', 'cliq-store-locator') . '</div>';
        }

        // Only top-level categories on first paint — the JS drill-down (which
        // shows sub-categories + a "View all" tile) takes over on mount, so
        // this matches its initial state instead of flashing every category.
        $topLevel = array_values(array_filter($cats, static fn (array $c): bool => (int) ($c['parent_id'] ?? 0) === 0));

        $tiles = $this->categoryRenderer->renderList($catModel, $topLevel, [
            'scope' => 'cliq-widget-d-r' . $categoryTileDesignId,
            'wrapperClass' => static fn (array $c): string => 'cliq-sl-cat cliq-sl-cat-c' . (int) ($c['id'] ?? 0),
            'autoColorVar' => static fn (array $c) => $c['color'] ?? null,
        ]);

        return '<div class="cliq-sl-catgrid">' . $tiles . '</div>';
    }

    /** @param array<string, mixed> $ws */
    private function emptyResults(array $ws): string
    {
        $text = (string) ($ws['emptyText'] ?? '') !== ''
            ? (string) $ws['emptyText']
            : __('No results found.', 'cliq-store-locator');

        return '<div class="cliq-sl-results cliq-sl-results--empty">' . esc_html($text) . '</div>';
    }

    /**
     * Results-list container class attribute — modifier classes for the
     * discrete layout modes only. The continuous values (gap/scroll
     * height/grid columns) are emitted as a GENERATED rule keyed on the
     * widget item's n-hook by widgetSettingsCss() below, never on the
     * element. PHP twin of designRender.js resultsContainerClass().
     */
    private function resultsContainerClassAttr(array $ws, string $extraClass = ''): string
    {
        $classes = ['cliq-widget-results'];
        if (! empty($ws['scroll'])) {
            $classes[] = 'cliq-widget-results--scroll';
        }
        if (($ws['layout'] ?? '') === 'grid') {
            $classes[] = 'cliq-widget-results--grid';
        } elseif (($ws['direction'] ?? '') === 'row') {
            $classes[] = 'cliq-widget-results--row';
        }
        if ($extraClass !== '') {
            $classes[] = $extraClass;
        }

        return ' class="' . esc_attr(implode(' ', $classes)) . '"';
    }

    /**
     * Widget SETTINGS → generated scoped CSS rules keyed on each widget item's
     * n-hook (element settings become stylesheet rules,
     * never style attributes). Appended to the template grid's generated
     * stylesheet in renderGrid(). JS twin: LocatorApp.js widgetSettingsCss().
     */
    private function widgetSettingsCss(array $model, string $scope): string
    {
        $rules = [];
        foreach ($this->rows($model) as $row) {
            foreach ((is_array($row['columns'] ?? null) ? $row['columns'] : []) as $col) {
                foreach ((is_array($col['items'] ?? null) ? $col['items'] : []) as $item) {
                    $item = is_array($item) ? $item : [];
                    if (($item['type'] ?? '') !== 'widget') {
                        continue;
                    }
                    $ws = is_array($item['widgetSettings'] ?? null) ? $item['widgetSettings'] : [];
                    $decls = '';
                    if (($item['widget'] ?? '') === 'results') {
                        // Accordion's list default gap (12) differs from the base
                        // rule's fallback (14) — normalise here, like the JS twin.
                        if (in_array(($ws['displayMode'] ?? 'list'), ['accordion', 'grouped'], true)) {
                            $ws = array_merge($ws, ['layout' => 'list', 'gap' => is_numeric($ws['gap'] ?? null) ? $ws['gap'] : 12]);
                        }
                        $decls = implode(';', array_filter([
                            $this->resultsContainerVars($ws),
                            $this->loadMoreButtonVars($ws),
                            $this->groupedAccordionVars($ws),
                            ($ws['resultCountColor'] ?? '') !== ''
                                ? '--cliq-sl-count-color:' . DesignRenderer::safeCssValue((string) $ws['resultCountColor'])
                                : '',
                        ]));
                    } elseif (($item['widget'] ?? '') === 'count') {
                        $decls = ($ws['textColor'] ?? '') !== ''
                            ? '--cliq-sl-count-color:' . DesignRenderer::safeCssValue((string) $ws['textColor'])
                            : '';
                    } elseif (($item['widget'] ?? '') === 'category') {
                        $decls = ($ws['display'] ?? '') === 'tiles'
                            ? $this->resultsContainerVars($ws)
                            : $this->categoryChipVars($ws);
                    } elseif (($item['widget'] ?? '') === 'map') {
						$markerNavBackground = ! empty($ws['markerNavigationBackground'])
							? DesignRenderer::safeCssValue((string) $ws['markerNavigationBackground'])
							: 'var(--cliq-sl-surface)';
						$requestedMarkerNavColor = ! empty($ws['markerNavigationColor'])
							? DesignRenderer::safeCssValue((string) $ws['markerNavigationColor'])
							: 'var(--cliq-sl-primary)';
						$markerNavColor = strtolower($requestedMarkerNavColor) === strtolower($markerNavBackground)
							? 'var(--cliq-sl-on-primary)'
							: $requestedMarkerNavColor;
                        $decls = implode(';', [
                            '--cliq-sl-map-height:' . ((int) ($ws['height'] ?? 480) ?: 480) . 'px',
                            '--cliq-map-search-top:' . ((int) ($ws['searchOverlayTop'] ?? 20)) . 'px',
                            '--cliq-map-search-width:' . ((int) ($ws['searchOverlayWidth'] ?? 640) ?: 640) . 'px',
							'--cliq-marker-nav-color:' . $markerNavColor,
							'--cliq-marker-nav-bg:' . $markerNavBackground,
                        ]);
                    }
                    if ($decls !== '') {
                        $rules[] = $scope . ' .cliq-widget-n-' . $this->nodeId($item) . '{' . $decls . '}';
                    }
                }
            }
        }

        return implode("\n", $rules);
    }

    /** Category-filter chip colours as scoped custom properties. */
    private function categoryChipVars(array $ws): string
    {
        $map = [
            'chipBackground' => '--cliq-chip-bg',
            'chipColor' => '--cliq-chip-color',
            'chipBorderColor' => '--cliq-chip-border',
            'chipHoverBackground' => '--cliq-chip-hover-bg',
            'chipHoverColor' => '--cliq-chip-hover-color',
            'chipActiveBackground' => '--cliq-chip-active-bg',
            'chipActiveColor' => '--cliq-chip-active-color',
            'chipActiveBorderColor' => '--cliq-chip-active-border',
        ];
        $decls = [];
        foreach ($map as $setting => $property) {
            if (($ws[$setting] ?? '') !== '') {
                $decls[] = $property . ':' . DesignRenderer::safeCssValue((string) $ws[$setting]);
            }
        }

        return implode(';', $decls);
    }

    /** Custom-property declarations for a results widget's continuous values
     * (PHP twin of designRender.js resultsContainerVars). */
    private function resultsContainerVars(array $ws): string
    {
        $decls = [];
        if (isset($ws['gap']) && $ws['gap'] !== '' && $ws['gap'] !== null) {
            $decls[] = '--cliq-results-gap:' . (is_numeric($ws['gap']) ? ($ws['gap'] + 0) . 'px' : DesignRenderer::safeCssValue((string) $ws['gap']));
        }
        if (! empty($ws['cardWidth'])) {
            $cardWidth = $ws['cardWidth'];
            $decls[] = '--cliq-results-card-w:' . (is_numeric($cardWidth) ? ($cardWidth + 0) . 'px' : DesignRenderer::safeCssValue((string) $cardWidth));
        }
        if (! empty($ws['scroll']) && ! empty($ws['maxHeight'])) {
            $decls[] = '--cliq-results-max-h:' . (is_numeric($ws['maxHeight']) ? ($ws['maxHeight'] + 0) . 'px' : DesignRenderer::safeCssValue((string) $ws['maxHeight']));
        }
        if (! empty($ws['scrollbarColor'])) {
            $decls[] = '--cliq-results-scroll-thumb:' . DesignRenderer::safeCssValue((string) $ws['scrollbarColor']);
        }
        if (! empty($ws['scrollbarTrackColor'])) {
            $decls[] = '--cliq-results-scroll-track:' . DesignRenderer::safeCssValue((string) $ws['scrollbarTrackColor']);
        }
        if (($ws['layout'] ?? '') === 'grid') {
            $min = is_numeric($ws['minColWidth'] ?? null) ? $ws['minColWidth'] + 0 : 300;
            $colMode = ($ws['colMode'] ?? '') === 'auto-fit' ? 'auto-fit' : 'auto-fill';
            $count = is_numeric($ws['gridColumns'] ?? null) ? (int) $ws['gridColumns'] : 4;
            $count = min(12, max(1, $count));
            $gap = is_numeric($ws['gap'] ?? null) ? max(0, (float) $ws['gap']) : 14;
            $reservedGap = $gap * ($count - 1);
            $reservedGap = (int) $reservedGap == $reservedGap ? (string) (int) $reservedGap : rtrim(rtrim((string) $reservedGap, '0'), '.');
            $trackMin = 'min(100%, max(' . $min . 'px, calc((100% - ' . $reservedGap . 'px) / ' . $count . ')))';
            $decls[] = '--cliq-results-cols:repeat(' . $colMode . ', minmax(' . $trackMin . ', 1fr))';
        }

        // Category TILE GRID variables — its own "Category tiles" settings,
        // independent of the store list. Both the Results categories drill-down
        // (displayMode) and the Category filter's tiles mode (display) use them.
        if (($ws['displayMode'] ?? '') === 'categories' || ($ws['display'] ?? '') === 'tiles') {
            $tMin = is_numeric($ws['tileMinWidth'] ?? null) ? $ws['tileMinWidth'] + 0 : (is_numeric($ws['minColWidth'] ?? null) ? $ws['minColWidth'] + 0 : 160);
            $tMode = (($ws['tileColMode'] ?? ($ws['colMode'] ?? '')) === 'auto-fit') ? 'auto-fit' : 'auto-fill';
            $decls[] = '--cliq-cat-cols:repeat(' . $tMode . ', minmax(' . $tMin . 'px, 1fr))';
            $tGap = $ws['tileGap'] ?? ($ws['gap'] ?? null);
            if ($tGap !== null && $tGap !== '') {
                $decls[] = '--cliq-cat-gap:' . (is_numeric($tGap) ? ($tGap + 0) . 'px' : DesignRenderer::safeCssValue((string) $tGap));
            }
        }

        return implode(';', $decls);
    }

    /** Load More button style declarations (PHP twin of designRender.js
     * loadMoreButtonVars). @param array<string, mixed> $ws */
    private function loadMoreButtonVars(array $ws): string
    {
        $decls = [];
        if (! empty($ws['loadMoreFontFamily'])) {
            $decls[] = '--cliq-loadmore-font-family:' . DesignRenderer::safeCssValue((string) $ws['loadMoreFontFamily']);
        }
        if (! empty($ws['loadMoreFontSize'])) {
            $decls[] = '--cliq-loadmore-font-size:' . (is_numeric($ws['loadMoreFontSize']) ? ($ws['loadMoreFontSize'] + 0) . 'px' : DesignRenderer::safeCssValue((string) $ws['loadMoreFontSize']));
        }
        if (! empty($ws['loadMoreFontWeight'])) {
            $decls[] = '--cliq-loadmore-font-weight:' . DesignRenderer::safeCssValue((string) $ws['loadMoreFontWeight']);
        }
        if (! empty($ws['loadMoreColor'])) {
            $decls[] = '--cliq-loadmore-color:' . DesignRenderer::safeCssValue((string) $ws['loadMoreColor']);
        }
        if (! empty($ws['loadMoreBackground'])) {
            $decls[] = '--cliq-loadmore-background:' . DesignRenderer::safeCssValue((string) $ws['loadMoreBackground']);
        }
        if (! empty($ws['loadMoreBorderColor'])) {
            $decls[] = '--cliq-loadmore-border-color:' . DesignRenderer::safeCssValue((string) $ws['loadMoreBorderColor']);
        }
        if (! empty($ws['loadMoreBorderWidth'])) {
            $decls[] = '--cliq-loadmore-border-width:' . (is_numeric($ws['loadMoreBorderWidth']) ? ($ws['loadMoreBorderWidth'] + 0) . 'px' : DesignRenderer::safeCssValue((string) $ws['loadMoreBorderWidth']));
        }
        if (! empty($ws['loadMoreBorderRadius'])) {
            $decls[] = '--cliq-loadmore-border-radius:' . (is_numeric($ws['loadMoreBorderRadius']) ? ($ws['loadMoreBorderRadius'] + 0) . 'px' : DesignRenderer::safeCssValue((string) $ws['loadMoreBorderRadius']));
        }
        if (! empty($ws['loadMorePaddingVertical']) || ! empty($ws['loadMorePaddingHorizontal'])) {
            $vertical = $ws['loadMorePaddingVertical'] ?? '9px';
            $horizontal = $ws['loadMorePaddingHorizontal'] ?? '22px';
            $vertical = $vertical !== '' ? $vertical : '9px';
            $horizontal = $horizontal !== '' ? $horizontal : '22px';
            $decls[] = '--cliq-loadmore-padding:'
                . (is_numeric($vertical) ? ($vertical + 0) . 'px' : DesignRenderer::safeCssValue((string) $vertical))
                . ' '
                . (is_numeric($horizontal) ? ($horizontal + 0) . 'px' : DesignRenderer::safeCssValue((string) $horizontal));
        }
        if (! empty($ws['loadMoreHoverColor'])) {
            $decls[] = '--cliq-loadmore-hover-color:' . DesignRenderer::safeCssValue((string) $ws['loadMoreHoverColor']);
        }
        if (! empty($ws['loadMoreHoverBackground'])) {
            $decls[] = '--cliq-loadmore-hover-background:' . DesignRenderer::safeCssValue((string) $ws['loadMoreHoverBackground']);
        }
        if (! empty($ws['loadMoreHoverBorderColor'])) {
            $decls[] = '--cliq-loadmore-hover-border-color:' . DesignRenderer::safeCssValue((string) $ws['loadMoreHoverBorderColor']);
        }

        return implode(';', $decls);
    }

    /** @param array<string, mixed> $ws */
    private function groupedAccordionVars(array $ws): string
    {
        $map = [
            'groupClosedBackground' => '--cliq-group-closed-bg',
            'groupOpenBackground' => '--cliq-group-open-bg',
            'groupCountBackground' => '--cliq-group-count-bg',
            'groupCountColor' => '--cliq-group-count-color',
            'groupCountRadius' => '--cliq-group-count-radius',
            'groupCountFontFamily' => '--cliq-group-count-font-family',
            'groupCountFontSize' => '--cliq-group-count-font-size',
            'groupCountFontWeight' => '--cliq-group-count-font-weight',
        ];
        $decls = [];
        foreach ($map as $setting => $property) {
            if (($ws[$setting] ?? '') !== '') {
                $decls[] = $property . ':' . DesignRenderer::safeCssValue((string) $ws[$setting]);
            }
        }

        return implode(';', $decls);
    }

    /* ---- map (port of widgets.js MapWidget markup) --------------------------- */

    /** @param array<string, mixed> $ws @param array<string, mixed> $ctx */
    private function renderMap(array $ws, array $ctx): string
    {
        // Height arrives via the generated widget-settings rule
        // (--cliq-sl-map-height on this widget's cell hook, widgetSettingsCss)
        // — consumed by .cliq-sl-map-wrap in style.scss. The map itself
        // initialises client-side into this wrapper; SSR only reserves the
        // space (no CLS) and carries the a11y region label.
        $placement = ($ws['directionsPlacement'] ?? '') === 'results' ? 'results' : 'map';
        $html = '<div class="cliq-sl-w cliq-sl-w--map cliq-sl-map-wrap" data-directions-placement="' . esc_attr($placement) . '">'
            . '<aside class="cliq-sl-directions" aria-hidden="true">'
            . '<div class="cliq-sl-directions__head">'
            . '<strong>' . esc_html__('Store direction', 'cliq-store-locator') . '</strong>'
            . '<button type="button" class="cliq-sl-directions__close" aria-label="' . esc_attr__('Close', 'cliq-store-locator') . '">×</button>'
            . '</div>'
            . '<div class="cliq-sl-directions__steps"></div>'
            . '</aside>'
            . '<div class="cliq-sl-map" role="region" aria-label="' . esc_attr__('Store locations map', 'cliq-store-locator') . '"></div>';

        $overlayFormId = (int) ($ws['overlaySearchFormId'] ?? 0);
        if (! empty($ws['showSearchOverlay']) && $overlayFormId > 0) {
            $position = in_array(($ws['searchOverlayPosition'] ?? ''), ['left', 'center', 'right'], true)
                ? (string) $ws['searchOverlayPosition']
                : 'center';
            $html .= '<div class="cliq-sl-map-search-overlay is-' . esc_attr($position) . '">'
                . $this->renderSearchForm(['searchFormId' => $overlayFormId], $ctx)
                . '</div>';
        }

        if (! empty($ws['showMarkerNavigation'])) {
            $navPosition = in_array(($ws['markerNavigationPosition'] ?? ''), ['sides', 'top-start', 'top-center', 'top-end'], true)
                ? (string) $ws['markerNavigationPosition']
                : 'sides';
            $html .= '<div class="cliq-sl-marker-navs is-' . esc_attr($navPosition) . '"><button type="button" class="cliq-sl-marker-nav cliq-sl-marker-nav--prev" aria-label="' . esc_attr__('Previous store marker', 'cliq-store-locator') . '">'
                . $this->renderer->iconContent(['icon' => (string) ($ws['markerPrevIcon'] ?? 'chevron-left')], '')
                . '</button>'
                . '<button type="button" class="cliq-sl-marker-nav cliq-sl-marker-nav--next" aria-label="' . esc_attr__('Next store marker', 'cliq-store-locator') . '">'
                . $this->renderer->iconContent(['icon' => (string) ($ws['markerNextIcon'] ?? 'chevron-right')], '')
                . '</button></div>';
        }

        return $html . '</div>';
    }

    /* ---- shared helpers ------------------------------------------------------ */

    /** The /categories payload, resolved once per render. */
    private function categories(): array
    {
        if ($this->categories === null) {
            $data = $this->api->categories()->get_data();
            $this->categories = is_array($data['categories'] ?? null) ? $data['categories'] : [];
        }

        return $this->categories;
    }

    /** id → design model from the embedded designs map (port of modelOf()). */
    private function designModel(array $designs, int $id): ?array
    {
        // 0 means "no design selected". A saved design has a positive id; the
        // bundled layouts this plugin ships use negative ids, so the test is
        // against zero and never against sign.
        if ($id === 0) {
            return null;
        }
        $model = $designs[(string) $id]['model'] ?? null;
        if (is_string($model)) {
            $model = json_decode($model, true);
        }

        return is_array($model) && is_array($model['rows'] ?? null) ? $model : null;
    }

    /** @return array<int, array<string, mixed>> */
    private function rows(array $model): array
    {
        $rows = is_array($model['rows'] ?? null) ? $model['rows'] : [];

        return array_values(array_filter($rows, 'is_array'));
    }

    /**
     * First widget of a type in the locator grid → its widgetSettings (mirrors
     * the JS findWidget()). Empty array when the widget isn't placed.
     *
     * @return array<string, mixed>
     */
    public static function firstWidgetSettings(array $model, string $widget): array
    {
        foreach ($model['rows'] ?? [] as $row) {
            foreach ($row['columns'] ?? [] as $col) {
                foreach ($col['items'] ?? [] as $item) {
                    if (($item['type'] ?? '') === 'widget' && ($item['widget'] ?? '') === $widget) {
                        return is_array($item['widgetSettings'] ?? null) ? $item['widgetSettings'] : [];
                    }
                }
            }
        }

        return [];
    }

    /** CSS-safe id for a node (row/col/item) — same filter as DesignRenderer. */
    private function nodeId(array $node): string
    {
        return (string) preg_replace('/[^a-z0-9_-]/i', '', (string) ($node['id'] ?? ''));
    }
}
