<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Frontend;

defined('ABSPATH') || exit;

/**
 * Server-side renderer for a builder design (rows/columns/items) — the PHP
 * counterpart of resources/shared/designRender.js. Used to render the store
 * detail page as real HTML (SEO/meta, no client fetch), reusing the same
 * `.cliq-*` classes so it looks identical to the builder preview / locator.
 *
 * Token resolution is driven by the host token metadata (format/source); the
 * renderer has no hard-coded field knowledge — same contract as the JS version.
 */
final class DesignRenderer
{
    /** @var array<string, bool> optional renderers that produced output */
    private array $used = [];

    private const STYLE_HANDLE = 'cliq-sl-frontend';

    /** @var array<int, array<string, mixed>> */
    private array $tokens;
    /** @var array<string, array{property:string, selector:string}> control-key → css target */
    private array $cssMap;

    /**
     * @param array<int, array<string, mixed>> $tokens
     */
    public function __construct(array $tokens)
    {
        $this->tokens = $tokens;
        // Where each css control applies (control-key → property + selector). Same
        // PHP source the builder injects to JS, so server + client render identically.
        $this->cssMap = \CliqThemes\StoreLocator\Support\DesignRuntimeConfig::cssMap();
        // Ensure the store-locator domain token formats are hooked onto the
        // `cliq_sl_token_value` filter (idempotent) so the renderer resolves them
        // regardless of boot order. Generic formats are handled inline below.
        TokenFormats::register();
    }

    /**
     * Record that an optional renderer produced output during this render.
     *
     * The host enqueues a feature's assets only when a design actually used it,
     * which only the renderer knows — a referenced field may render empty.
     */
    public function markUsed(string $feature): void
    {
        $this->used[sanitize_key($feature)] = true;
    }

    /** Did an optional renderer produce output during this render? */
    public function used(string $feature): bool
    {
        return isset($this->used[sanitize_key($feature)]);
    }

    /**
     * @param array<string, mixed> $model design model ({rows, container})
     * @param array<string, mixed> $store render-ready store payload
     */
    public function render(array $model, array $store): string
    {
        $rows = is_array($model['rows'] ?? null) ? $model['rows'] : [];
        if ($rows === []) {
            return '';
        }

        $scope = 'cliq-widget-d-' . substr(str_replace('-', '', wp_generate_uuid4()), 0, 7);
        $css = $this->buildCss($model, '.' . $scope);

        $designLink = $this->nodeLink(is_array($model['container'] ?? null) ? $model['container'] : [], $store);
        $linkedClass = $designLink !== null ? ' cliq-widget-design--linked' : '';
        $html = '<div class="cliq-widget cliq-widget-design cliq-widget-design--layout cliq-sl-detail-design' . $linkedClass . ' ' . esc_attr($scope) . '"'
            . $this->containerLinkAttrs($designLink) . '>';
        $this->enqueueCss($css);
        foreach ($rows as $row) {
            $html .= $this->renderRow(is_array($row) ? $row : [], $store);
        }
        if (! empty($model['container']['tail'])) {
            $html .= '<div class="cliq-widget-marker-tail"></div>';
        }
        $html .= '</div>';

        /**
         * Filter the rendered design HTML (list item / infobox / detail layout)
         * before output — lets integrators wrap, augment, or replace the markup.
         *
         * @param string               $html  Rendered HTML.
         * @param array<string, mixed> $model Design model (rows/columns/items).
         * @param array<string, mixed> $store Store record being rendered.
         */
        return apply_filters('cliq_sl_design_html', $html, $model, $store);
    }

    /**
     * Render one design (a Store Card / list_item) for MANY stores, sharing a
     * single scope + stylesheet. Used by the locator's server-side (SEO) render so
     * the initial HTML carries real, crawlable store cards without emitting the
     * design's CSS once per card. Each card mirrors the frontend StoreList markup
     * (`.cliq-sl-result > .cliq-widget-design--layout`).
     *
     * @param array<string, mixed>             $model   design model ({rows, container})
     * @param array<int, array<string, mixed>> $stores  render-ready store payloads
     * @param array<string, mixed>             $options scope: deterministic scope class
     *        (default: random); interactive: add role="button"/tabindex like the live
     *        StoreList; accordion: AccordionResults markup (.cliq-sl-acc cards with a
     *        collapsed reveal region for rows flagged settings.collapsible); wrapperClass:
     *        callable(array $record): string — swaps the default 'cliq-sl-result' wrapper
     *        for a per-record class (e.g. category tiles' 'cliq-sl-cat-c{id}'); autoColorVar:
     *        callable(array $record): ?string — per-record --cliq-auto-color value for the
     *        "Automatic" background colour option (Colour section); requires wrapperClass
     *        (the generated rule is keyed to that same class).
     */
    public function renderList(array $model, array $stores, array $options = []): string
    {
        $rows = is_array($model['rows'] ?? null) ? $model['rows'] : [];
        if ($rows === [] || $stores === []) {
            return '';
        }

        $scope = (string) ($options['scope'] ?? '');
        if ($scope === '' || preg_replace('/[^a-z0-9_-]/i', '', $scope) !== $scope) {
            $scope = 'cliq-widget-d-' . substr(str_replace('-', '', wp_generate_uuid4()), 0, 7);
        }
        $accordion = ! empty($options['accordion']);
        $interactive = ! empty($options['interactive']);
        // Optional per-record wrapper class override — callable(array $record): string.
        // Used by non-store record lists (e.g. category tiles) that need their own
        // stable per-record class (for drill-down click targeting / colour-tint
        // hooks) instead of the store-card 'cliq-sl-result' wrapper. Absent =
        // byte-identical to today's behaviour.
        $wrapperClass = is_callable($options['wrapperClass'] ?? null) ? $options['wrapperClass'] : null;
        $autoColorVar = is_callable($options['autoColorVar'] ?? null) ? $options['autoColorVar'] : null;
        $css = $this->buildCss($model, '.' . $scope);

        $out = '';
        $this->enqueueCss($css);
        if ($autoColorVar !== null && $wrapperClass !== null) {
            $this->enqueueCss($this->autoColorVarCss($stores, $wrapperClass, $autoColorVar));
        }
        $designClass = 'cliq-widget cliq-widget-design cliq-widget-design--layout ' . esc_attr($scope);
        foreach ($stores as $store) {
            if (! is_array($store)) {
                continue;
            }
            $body = $accordion
                ? $this->renderAccordionBody($rows, $store)
                : $this->renderRows($rows, $store);
            $designLink = $this->nodeLink(is_array($model['container'] ?? null) ? $model['container'] : [], $store);
            $recordDesignClass = $designClass . ($designLink !== null ? ' cliq-widget-design--linked' : '');
            $designAttrs = $this->containerLinkAttrs($designLink);
            if ($accordion) {
                // Mirrors AccordionResults (widgets.js): one collapsed card per store
                // with the chevron affordance; the app wires expansion on takeover.
                $out .= '<div class="cliq-sl-acc" role="button" tabindex="0" aria-expanded="false">'
                    . '<div class="' . $recordDesignClass . '"' . $designAttrs . '>' . $body . '</div>'
                    . '<span class="cliq-sl-acc__chev" aria-hidden="true">›</span>'
                    . '</div>';
            } elseif ($wrapperClass !== null) {
                $cls = esc_attr((string) $wrapperClass($store));
                $out .= '<button type="button" class="' . $cls . '"><div class="' . $recordDesignClass . '"' . $designAttrs . '>' . $body . '</div></button>';
            } else {
                $attrs = $interactive ? ' role="button" tabindex="0"' : '';
                $out .= '<div class="cliq-sl-result"' . $attrs . '><div class="' . $recordDesignClass . '"' . $designAttrs . '>' . $body . '</div></div>';
            }
        }

        return $out;
    }

    /**
     * One generated rule per record setting --cliq-auto-color to that record's
     * own colour (e.g. a category's Colour field), keyed by the SAME class
     * wrapperClass() assigns it — the "Automatic" background option (Colour
     * section) then just reads var(--cliq-auto-color, <fallback>) in the
     * item's own (single, shared) rule. Mirrors CategoryBrowser's per-category
     * colour rule (widgets.js).
     *
     * @param array<int, array<string, mixed>> $stores
     */
    private function autoColorVarCss(array $stores, callable $wrapperClass, callable $autoColorVar): string
    {
        $rules = '';
        foreach ($stores as $store) {
            if (! is_array($store)) {
                continue;
            }
            $color = (string) ($autoColorVar($store) ?? '');
            if ($color === '') {
                continue;
            }
            $selector = '.' . str_replace(' ', '.', trim((string) $wrapperClass($store)));
            $rules .= $selector . '{--cliq-auto-color:' . self::cssSafe($color) . '}';
        }

        return $rules;
    }

    /**
     * Attach generated, scoped design rules to the plugin's registered frontend
     * stylesheet. Keeping these rules in WordPress's style registry avoids
     * printing ad-hoc style elements from shortcode and SSR markup.
     */
    public function enqueueCss(string $css): void
    {
        // Escaped late, at the point of output. Every value was already run
        // through cssSafe() where its declaration was built, but the only way a
        // stylesheet becomes script is a tag closing the <style> element that
        // carries it — so strip tags here, where the CSS leaves this class.
        // Valid CSS contains no tags; `>` child combinators are untouched.
        $css = trim(wp_strip_all_tags($css));
        if ($css === '' || ! function_exists('wp_add_inline_style')) {
            return;
        }

        if (! wp_style_is(self::STYLE_HANDLE, 'registered')) {
            wp_register_style(
                self::STYLE_HANDLE,
                false,
                [],
                defined('CLIQ_SL_VERSION') ? CLIQ_SL_VERSION : null
            );
        }
        wp_enqueue_style(self::STYLE_HANDLE);
        wp_add_inline_style(self::STYLE_HANDLE, $css);
    }

    /** @param array<int, mixed> $rows */
    private function renderRows(array $rows, array $store): string
    {
        $body = '';
        foreach ($rows as $row) {
            $body .= $this->renderRow(is_array($row) ? $row : [], $store);
        }

        return $body;
    }

    /**
     * Accordion card body (mirror of DesignView's accordion branch): header rows
     * first, then the rows flagged `settings.collapsible` inside a closed reveal
     * region (`.cliq-widget-reveal` without is-open).
     *
     * @param array<int, mixed> $rows
     */
    private function renderAccordionBody(array $rows, array $store): string
    {
        $always = [];
        $reveal = [];
        foreach ($rows as $row) {
            $row = is_array($row) ? $row : [];
            if (! empty($row['settings']['collapsible'])) {
                $reveal[] = $row;
            } else {
                $always[] = $row;
            }
        }

        $body = $this->renderRows($always, $store);
        if ($reveal !== []) {
            $body .= '<div class="cliq-widget cliq-widget-reveal">'
                . '<div class="cliq-widget cliq-widget-reveal__inner">'
                . $this->renderRows($reveal, $store)
                . '</div></div>';
        }

        return $body;
    }

    /** Public wrapper over buildCss() for the locator SSR renderer (scoped design stylesheet). */
    public function designCss(array $model, string $scope): string
    {
        return $this->buildCss($model, $scope);
    }

    /** Public wrapper over nodeClass() for the locator SSR renderer. */
    public function elementClass(string $base, array $node): string
    {
        return $this->nodeClass($base, $node);
    }

    /** Public wrapper over withIcon(): prepend/append an item's icon glyph to
     * already-escaped content (mirrors the JS WithIcon). */
    public function iconContent(array $item, string $escapedContent): string
    {
        return $this->withIcon($item, $escapedContent);
    }

    /** Public wrapper over cssSafe() for single authored CSS values used in
     * structural inline styles (gap/align/…). */
    public static function safeCssValue(string $value): string
    {
        return self::cssSafe($value);
    }

    /**
     * Build the scoped stylesheet for a design (mirror of designRender.js
     * buildDesignCss): visual CSS becomes rules under the scope class, mobile
     * overrides go in a media query for real frontend responsiveness, and per-item
     * hover rules are included. Structural layout stays inline (see renderRow).
     *
     * @param array<string, mixed> $model
     */
    private function buildCss(array $model, string $scope): string
    {
        $desk = [];
        $tablet = [];
        $mobile = [];
        // Emit rule(s) for one element: structural layout + mapped css on the element
        // selector, plus a separate rule per child selector (e.g. ' svg'). Mapped keys
        // are composed by the cssMap; everything else by cssToStyle() — never both.
        $emitInto = function (array &$target, string $sel, string $structural, $css): void {
            $parts = $this->splitCss(is_array($css) ? $css : []);
            $base = $structural . ($parts['bySel'][''] ?? '') . $this->cssToStyle($parts['unmapped']);
            if (trim($base) !== '') {
                $target[] = $sel . '{' . $base . '}';
            }
            foreach ($parts['bySel'] as $childSel => $decls) {
                if ($childSel === '' || $decls === '') {
                    continue;
                }
                $target[] = $sel . $childSel . '{' . $decls . '}';
            }
        };
        $emit = function (string $sel, string $structural, $css, $cssMobile) use (&$desk, &$tablet, $emitInto): void {
            $emitInto($desk, $sel, $structural, $css);
            if (is_array($cssMobile) && $cssMobile !== []) {
                $emitInto($tablet, $sel, '', $cssMobile);
            }
        };

        $container = is_array($model['container'] ?? null) ? $model['container'] : [];
        // Container is flex+column by default (twin of designRender.js) so its own
        // Justify/Align controls work out of the box; flex-column + default
        // stretch/flex-start is layout-equivalent to the old block flow, so nothing
        // existing shifts. Stored css (incl. display:grid) overrides these.
        $containerCss = array_merge(['display' => 'flex', 'flexDirection' => 'column'], is_array($container['css'] ?? null) ? $container['css'] : []);
        $emit($scope, '', $containerCss, $container['cssMobile'] ?? []);
        if (is_array($model['settings'] ?? null)) {
            $formSettings = $model['settings'];
            $preset = (string) ($formSettings['controlHeightPreset'] ?? 'medium');
            $heights = ['small' => '38px', 'medium' => '46px', 'large' => '54px'];
            $heightRaw = $preset === 'custom'
                ? ($formSettings['controlHeightCustom'] ?? '46px')
                : ($heights[$preset] ?? $heights['medium']);
            $height = is_numeric($heightRaw) ? ($heightRaw + 0) . 'px' : self::cssSafe((string) $heightRaw);
            $labelFamily = str_replace(
                ["'", '"'],
                '',
                self::cssSafe((string) ($formSettings['labelFontFamily'] ?? ''))
            );
            $labelSizeRaw = $formSettings['labelFontSize'] ?? '12px';
            $labelSize = is_numeric($labelSizeRaw) ? ($labelSizeRaw + 0) . 'px' : self::cssSafe((string) $labelSizeRaw);
            $labelWeight = in_array((string) ($formSettings['labelFontWeight'] ?? '600'), ['400', '500', '600', '700'], true)
                ? (string) $formSettings['labelFontWeight']
                : '600';
            $labelColor = self::cssSafe((string) ($formSettings['labelColor'] ?? '')) ?: 'var(--cliq-sl-label)';
            $labelGapRaw = $formSettings['labelGap'] ?? '6px';
            $labelGap = is_numeric($labelGapRaw) ? ($labelGapRaw + 0) . 'px' : self::cssSafe((string) $labelGapRaw);
            $lineHeight = self::cssSafe((string) ($formSettings['labelLineHeight'] ?? '1.25')) ?: '1.25';
            $wrapLabels = ($formSettings['labelOverflow'] ?? 'truncate') === 'wrap';
            $desk[] = $scope . '{--cliq-sl-field-height:' . ($height ?: '46px')
                . ';--cliq-sl-label-font-family:' . ($labelFamily !== '' ? "'" . $labelFamily . "',sans-serif" : 'inherit')
                . ';--cliq-sl-label-size:' . ($labelSize ?: '12px')
                . ';--cliq-sl-label-weight:' . $labelWeight
                . ';--cliq-sl-label-color:' . $labelColor
                . ';--cliq-sl-label-gap:' . ($labelGap ?: '6px')
                . ';--cliq-sl-label-line-height:' . $lineHeight
                . ';--cliq-sl-label-white-space:' . ($wrapLabels ? 'normal' : 'nowrap')
                . ';--cliq-sl-label-overflow:' . ($wrapLabels ? 'visible' : 'hidden')
                . ';--cliq-sl-label-text-overflow:' . ($wrapLabels ? 'clip' : 'ellipsis') . '}';
        }
        if (! empty($container['activeBackground'])) {
            $desk[] = '.cliq-sl-result.is-active>' . $scope . '{background:' . self::cssSafe((string) $container['activeBackground']) . '}';
        }
        // Pointer tail (map-marker use): mirrors designRender.js buildDesignCss() —
        // absolutely positioned below the container so it shows correctly whether
        // the container auto-sizes or (as a pin/icon-style marker often does) has
        // an explicit fixed width/height. See that file for the full rationale.
        if (! empty($container['tail'])) {
            $g = self::tailGeometry($container['tailSize'] ?? null);
            $tailColor = self::cssSafe((string) ($container['tailColor'] ?? ($container['css']['background'] ?? '#1d4ed8')));
            // --cliq-tail-offset: read by the host's marker-anchor rule (a
            // child-selector rule in its stylesheet) — generated, never inline.
            $desk[] = $scope . '{position:relative;--cliq-tail-offset:' . $g['netOffset'] . 'px}';
            // Pulled up by `overlap` so its flat top edge tucks under a rounded/
            // circular container instead of floating below it with a visible gap.
            $desk[] = $scope . ' .cliq-widget-marker-tail{position:absolute;left:50%;top:calc(100% - ' . $g['overlap'] . 'px);transform:translateX(-50%);width:0;height:0;border-left:' . $g['halfWidth'] . 'px solid transparent;border-right:' . $g['halfWidth'] . 'px solid transparent;border-top:' . $g['height'] . 'px solid ' . $tailColor . ';pointer-events:none}';
        }

        foreach ((is_array($model['rows'] ?? null) ? $model['rows'] : []) as $row) {
            $rs = is_array($row['settings'] ?? null) ? $row['settings'] : [];
            $gap = $rs['gap'] ?? 16;
            $rowStruct = 'gap:' . (is_numeric($gap) ? $gap . 'px' : self::cssSafe((string) $gap)) . ';';
            if (! empty($rs['align'])) {
                $rowStruct .= 'align-items:' . self::cssSafe((string) $rs['align']) . ';';
            }
            $rowHook = $scope . ' .cliq-widget-n-' . $this->nodeId($row);
            $emit($rowHook, $rowStruct, $rs['css'] ?? [], $rs['cssMobile'] ?? []);
            if (! empty($rs['equalColumnHeight'])) {
                $rawHeight = $rs['equalColumnHeightValue'] ?? '600px';
                $height = is_numeric($rawHeight)
                    ? ($rawHeight + 0) . 'px'
                    : self::cssSafe((string) $rawHeight);
                if ($height === '') {
                    $height = '600px';
                }
                $desk[] = $rowHook . '{grid-auto-rows:' . $height . ';align-items:stretch}'
                    . $rowHook . '>.cliq-widget-col{height:100%;min-height:0}';
                foreach ((is_array($row['columns'] ?? null) ? $row['columns'] : []) as $equalColumn) {
                    $equalItems = is_array($equalColumn['items'] ?? null) ? array_values($equalColumn['items']) : [];
                    $flexibleIndex = null;
                    foreach ($equalItems as $equalIndex => $equalItem) {
                        if (($equalItem['widget'] ?? '') === 'results') {
                            $flexibleIndex = $equalIndex;
                            break;
                        }
                    }
                    if ($flexibleIndex === null) {
                        foreach ($equalItems as $equalIndex => $equalItem) {
                            if (($equalItem['widget'] ?? '') === 'map') {
                                $flexibleIndex = $equalIndex;
                                break;
                            }
                        }
                    }
                    if ($flexibleIndex === null) {
                        continue;
                    }
                    $flexibleItem = $equalItems[$flexibleIndex];
                    $tracks = [];
                    foreach ($equalItems as $equalIndex => $unused) {
                        $tracks[] = $equalIndex === $flexibleIndex ? 'minmax(0,1fr)' : 'auto';
                    }
                    $columnHook = $scope . ' .cliq-widget-n-' . $this->nodeId($equalColumn);
                    $cellHook = $scope . ' .cliq-widget-cl-' . $this->nodeId($flexibleItem);
                    $desk[] = $columnHook . '{grid-template-rows:' . implode(' ', $tracks) . ';align-content:stretch}'
                        . $cellHook . '{height:100%;min-height:0}';
                    if (($flexibleItem['widget'] ?? '') === 'map') {
                        $desk[] = $cellHook . '>.cliq-sl-w--map{height:100%;min-height:0}';
                    } else {
                        $desk[] = $cellHook . '>.cliq-sl-w--results{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}'
                            . $cellHook . '>.cliq-sl-w--results>:is(.cliq-sl-results,.cliq-sl-acc-list,.cliq-sl-group-list,.cliq-sl-catbrowse,.cliq-sl-filter-drawer){flex:1 1 auto;min-height:0;max-height:none;overflow:auto;align-content:start}';
                    }
                }
            }
            foreach ((is_array($row['columns'] ?? null) ? $row['columns'] : []) as $col) {
                $cs = is_array($col['settings'] ?? null) ? $col['settings'] : [];
                $colStruct = 'grid-column:span ' . (int) ($col['span'] ?? 12) . ';';
                if (! empty($cs['align'])) {
                    $colStruct .= 'align-content:' . self::cssSafe((string) $cs['align']) . ';';
                    $colStruct .= 'align-items:' . self::cssSafe((string) $cs['align']) . ';';
                }
                // Always scope the item-gap variable to this column. Otherwise a
                // nested design inherits the outer locator column's value and its
                // frontend spacing diverges from the standalone builder preview.
                $columnGap = isset($cs['gap']) && $cs['gap'] !== '' ? $cs['gap'] : 6;
                $colStruct .= '--cliq-col-gap:' . (is_numeric($columnGap) ? ($columnGap + 0) . 'px' : self::cssSafe((string) $columnGap)) . ';';
                $emit($scope . ' .cliq-widget-n-' . $this->nodeId($col), $colStruct, $cs['css'] ?? [], $cs['cssMobile'] ?? []);

                if (($cs['itemLayout'] ?? '') === 'equal') {
                    $visibleItemCount = max(1, count(array_filter(
                        is_array($col['items'] ?? null) ? $col['items'] : [],
                        static fn (array $item): bool => empty($item['css']['overlay'])
                    )));
                    $equalRule = function (mixed $raw) use ($scope, $col, $visibleItemCount): string {
                        $count = $raw === 'auto'
                            ? $visibleItemCount
                            : max(1, min(12, (int) $raw));
                        $hook = $scope . ' .cliq-widget-n-' . $this->nodeId($col);

                        return $hook . '{grid-template-columns:repeat(' . $count . ',minmax(0,1fr))}'
                            . $hook . '>.cliq-widget-cell{grid-column:auto}';
                    };
                    $desktopColumns = $cs['itemColumns'] ?? 'auto';
                    $tabletColumns = $cs['itemColumnsTablet'] ?? $desktopColumns;
                    $mobileColumns = $cs['itemColumnsMobile'] ?? 1;
                    $desk[] = $equalRule($desktopColumns);
                    $tablet[] = $equalRule($tabletColumns);
                    $mobile[] = $equalRule($mobileColumns);
                }
                if (! empty($cs['equalHeight'])) {
                    $rawHeight = $cs['equalHeightValue'] ?? '600px';
                    $height = is_numeric($rawHeight)
                        ? ($rawHeight + 0) . 'px'
                        : self::cssSafe((string) $rawHeight);
                    if ($height === '') {
                        $height = '600px';
                    }
                    $hook = $scope . ' .cliq-widget-n-' . $this->nodeId($col);
                    $desk[] = $hook . '{grid-auto-rows:' . $height . ';align-items:stretch}'
                        . $hook . '>.cliq-widget-cell{height:100%;min-height:0}'
                        . $hook . '>.cliq-widget-cell>:is(.cliq-w,.cliq-sl-w){height:100%;min-height:0}'
                        . $hook . '>.cliq-widget-cell>.cliq-sl-w--results{display:flex;flex-direction:column;overflow:hidden}'
                        . $hook . '>.cliq-widget-cell>.cliq-sl-w--results>:is(.cliq-sl-results,.cliq-sl-acc-list,.cliq-sl-group-list,.cliq-sl-catbrowse,.cliq-sl-filter-drawer){flex:1 1 auto;min-height:0;max-height:none;overflow:auto;align-content:start}';
                }
                // Responsive column width: phones default to full width (span 6 →
                // stacks) unless an explicit mobile span is set (mirrors designRender.js).
                $spanMobile = (isset($col['spanMobile']) && $col['spanMobile'] !== '' && $col['spanMobile'] !== null)
                    ? (int) $col['spanMobile']
                    : 12;
                $tablet[] = $scope . ' .cliq-widget-n-' . $this->nodeId($col) . '{grid-column:span ' . $spanMobile . '}';
                foreach ((is_array($col['items'] ?? null) ? $col['items'] : []) as $item) {
                    $icss = is_array($item['css'] ?? null) ? $item['css'] : [];
                    // Cell wrapper (own hook) carries the structural layout only.
                    $emit($scope . ' .cliq-widget-cl-' . $this->nodeId($item), $this->cellLayoutStyle($item), [], []);
                    // Responsive item width: keeps its desktop span on phones unless
                    // an explicit mobile span (colSpanMobile) is set (additive, so
                    // existing designs are unchanged). Mirrors the column rule above.
                    $itemSpanMobile = (isset($item['colSpanMobile']) && $item['colSpanMobile'] !== '' && $item['colSpanMobile'] !== null)
                        ? (int) $item['colSpanMobile']
                        : (int) ($item['colSpan'] ?? 12);
                    $tablet[] = $scope . ' .cliq-widget-cl-' . $this->nodeId($item) . '{grid-column:span ' . $itemSpanMobile . '}';
                    // Merge per-type look defaults UNDER the item css (overridable).
                    $merged = array_merge($this->defaultItemCss($item), $icss);
                    /**
                     * Extra CSS custom properties for one item's generated rule.
                     *
                     * @param string               $vars declarations, `--x:y;`
                     * @param array<string, mixed> $item the item being emitted
                     */
                    $itemVars = (string) apply_filters('cliq_sl_item_css_vars', '', $item);
                    $itemHook = $scope . ' ' . ($this->isButtonItem($item) ? ':is(button,a).' : '.') . 'cliq-widget-n-' . $this->nodeId($item);
                    $emit($itemHook, $itemVars, $merged, $item['cssMobile'] ?? []);
                    $heightMode = (string) ($item['controlHeightMode'] ?? 'form');
                    if ($heightMode === 'custom') {
                        $fieldHeightRaw = $item['controlHeightCustom'] ?? '46px';
                        $fieldHeight = is_numeric($fieldHeightRaw)
                            ? ($fieldHeightRaw + 0) . 'px'
                            : self::cssSafe((string) $fieldHeightRaw);
                        $fieldHeight = $fieldHeight !== '' ? $fieldHeight : '46px';
                        $desk[] = $scope . ' .cliq-sl-field-shell-' . $this->nodeId($item)
                            . ',' . $scope . ' .cliq-widget-n-' . $this->nodeId($item)
                            . '{--cliq-sl-field-height:' . $fieldHeight . '}';
                    } elseif ($heightMode === 'auto') {
                        $desk[] = $scope . ' .cliq-widget-n-' . $this->nodeId($item) . '{--cliq-sl-field-height:auto}';
                    }
                    // A Glide choice field's Dropdown-panel settings → --glide-* vars:
                    // scoped rule (the control carries the n-hook) + an UNSCOPED
                    // rule for Glide's <body>-portaled panel, which the frontend
                    // tags with the same n-hook at init (GlideSelect). Item ids
                    // are globally-unique uids, so the unscoped hook is safe.
                    $gpv = in_array((string) ($item['type'] ?? ''), ['select', 'chips', 'accordion', 'popup', 'options_list'], true)
                        ? self::glidePanelVars($icss)
                        : '';
                    if ($gpv !== '') {
                        $desk[] = $scope . ' .cliq-widget-n-' . $this->nodeId($item) . '{' . $gpv . '}';
                        $desk[] = '.glide-dropdown.cliq-widget-panel-n-' . $this->nodeId($item) . '{' . $gpv . '}';
                    }
                    // A range field's Slider-settings → --cui-slider-* vars: scoped
                    // rule only (the slider control doesn't portal outside the
                    // item's own DOM subtree the way Glide's dropdown panel does).
                    $sv = self::sliderVars($icss);
                    if ($sv !== '') {
                        $desk[] = $scope . ' .cliq-widget-n-' . $this->nodeId($item) . '{' . $sv . '}';
                    }
                    // A toggle field's Toggle-settings → plain declarations against
                    // its own track/thumb classes; the "on" colour needs a :checked
                    // rule, so this is a bespoke pass (like hover below), not the
                    // generic css.property pipeline.
					if (! empty($icss['toggleTrackOff']) || ! empty($icss['toggleTrackOn']) || ! empty($icss['toggleThumb']) || ! empty($icss['toggleInlineColor']) || ! empty($icss['toggleBorderColor'])) {
						$hook = $scope . ' .cliq-widget-n-' . $this->nodeId($item);
                        if (! empty($icss['toggleTrackOff'])) {
                            $desk[] = $hook . ' .cliq-sl-toggle__track{background:' . self::cssSafe((string) $icss['toggleTrackOff']) . '}';
                        }
                        if (! empty($icss['toggleTrackOn'])) {
                            $desk[] = $hook . ' .cliq-sl-toggle__input:checked + .cliq-sl-toggle__track{background:' . self::cssSafe((string) $icss['toggleTrackOn']) . '}';
                        }
						if (! empty($icss['toggleThumb'])) {
							$desk[] = $hook . ' .cliq-sl-toggle__thumb{background:' . self::cssSafe((string) $icss['toggleThumb']) . '}';
						}
						if (! empty($icss['toggleInlineColor'])) {
							$desk[] = $hook . ' .cliq-sl-toggle__inline{color:' . self::cssSafe((string) $icss['toggleInlineColor']) . '}';
						}
						if (! empty($icss['toggleBorderColor'])) {
							$desk[] = $hook . ' .cliq-sl-toggle__track{--cliq-sl-toggle-border:' . self::cssSafe((string) $icss['toggleBorderColor']) . '}';
						}
					}
                    if (! empty($icss['hoverBackground']) || ! empty($icss['hoverColor'])) {
                        $decls = [];
                        if (! empty($icss['hoverBackground'])) {
                            $decls[] = 'background:' . self::cssSafe((string) $icss['hoverBackground']);
                        }
                        if (! empty($icss['hoverColor'])) {
                            $decls[] = 'color:' . self::cssSafe((string) $icss['hoverColor']);
                        }
                        $desk[] = $scope . ' .cliq-widget-h-' . $this->nodeId($item) . ':hover{' . implode(';', $decls) . ' !important}';
                    }
                    $statusHook = $scope . ' .cliq-widget-n-' . $this->nodeId($item) . '.cliq-widget-open-status';
                    if (! empty($icss['openBackground']) || ! empty($icss['openColor'])) {
                        $decls = [];
                        if (! empty($icss['openBackground'])) {
                            $decls[] = 'background:' . self::cssSafe((string) $icss['openBackground']);
                        }
                        if (! empty($icss['openColor'])) {
                            $decls[] = 'color:' . self::cssSafe((string) $icss['openColor']);
                        }
                        $desk[] = $statusHook . '.is-open{' . implode(';', $decls) . '}';
                    }
                    if (! empty($icss['closedBackground']) || ! empty($icss['closedColor'])) {
                        $decls = [];
                        if (! empty($icss['closedBackground'])) {
                            $decls[] = 'background:' . self::cssSafe((string) $icss['closedBackground']);
                        }
                        if (! empty($icss['closedColor'])) {
                            $decls[] = 'color:' . self::cssSafe((string) $icss['closedColor']);
                        }
                        $desk[] = $statusHook . '.is-closed{' . implode(';', $decls) . '}';
                    }
                    array_push($desk, ...$this->tintOverlayCss($item, $scope, $merged));
                }
            }
        }

        if ($tablet !== []) {
            // Responsive breakpoint, single-sourced in DesignRuntimeConfig (Tablet
            // preview width) so SSR + builder preview + frontend all match.
            $bp = \CliqThemes\StoreLocator\Support\DesignRuntimeConfig::tabletBreakpoint();
            $desk[] = '@media (max-width:' . $bp . 'px){' . implode('', $tablet) . '}';
        }
        if ($mobile !== []) {
            $desk[] = '@media (max-width:' . \CliqThemes\StoreLocator\Support\DesignRuntimeConfig::mobileBreakpoint() . 'px){' . implode('', $mobile) . '}';
        }

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

    /**
     * Split a css bag into per-selector mapped declarations (driven by the cssMap)
     * and the leftover unmapped bag (composed by cssToStyle). Mirrors
     * designRender.js `split()`.
     * Returns ['bySel' => [selector => declString], 'unmapped' => [...]].
     *
     * @param array<string, mixed> $css
     * @return array{bySel: array<string, string>, unmapped: array<string, mixed>}
     */
    private function splitCss(array $css): array
    {
        $bySel = [];
        $unmapped = [];
        foreach ($css as $k => $val) {
            if ($val === null || $val === '') {
                continue;
            }
            if ($k === 'cssClass' || $k === 'backgroundAuto') {
                continue; // applied via nodeClass()/handled below, not a plain CSS property
            }
            if (! empty($this->cssMap[$k]['property'])) {
                $sel = (string) ($this->cssMap[$k]['selector'] ?? '');
                $bySel[$sel] = ($bySel[$sel] ?? '') . $this->composeDecl((string) $this->cssMap[$k]['property'], $val, ! empty($this->cssMap[$k]['unit']));
            } else {
                $unmapped[$k] = $val;
            }
        }

        // "Automatic" background (Colour group) overrides the static value
        // above with a per-record CSS custom property the caller sets on each
        // record's own wrapper — see renderList()'s autoColorVar option / the
        // JS twin (designRender.js split()). Appended last so it wins the
        // cascade whether or not a static background is also set; the static
        // value (if any) becomes the var()'s own fallback, so a record with no
        // colour of its own still shows something instead of going transparent.
        if (! empty($css['backgroundAuto']) && ! empty($this->cssMap['background']['property'])) {
            $sel = (string) ($this->cssMap['background']['selector'] ?? '');
            $fallback = (string) ($css['background'] ?? '');
            $value = 'var(--cliq-auto-color' . ($fallback !== '' ? ', ' . self::cssSafe($fallback) : '') . ')';
            $bySel[$sel] = ($bySel[$sel] ?? '') . $this->composeDecl((string) $this->cssMap['background']['property'], $value, false);
        }

        return ['bySel' => $bySel, 'unmapped' => $unmapped];
    }

    /**
     * Compose one mapped property by VALUE type: a {top,right,bottom,left} object
     * expands to longhands (padding-top: …), a scalar emits the shorthand. Generic —
     * works for any box-capable property. `$unit` adds a px fallback to bare numbers
     * (length controls only). Returns a ';'-terminated decl string.
     *
     * @param mixed $val
     */
    private function composeDecl(string $prop, $val, bool $unit): string
    {
        $dim = static fn ($v) => self::cssSafe(($unit && is_numeric($v)) ? $v . 'px' : (string) $v);
        if (is_array($val)) {
            $out = '';
            foreach (['top', 'right', 'bottom', 'left'] as $side) {
                if (! empty($val[$side])) {
                    $out .= $prop . '-' . $side . ':' . $dim($val[$side]) . ';';
                }
            }

            return $out;
        }

        return $prop . ':' . $dim($val) . ';';
    }

    /**
     * Neutralise an admin-authored CSS *value* so it cannot break out of its
     * declaration/rule or the generated stylesheet. Strips backslash (CSS
     * unicode escapes like \7d → }), and the structural chars ; { } < >. No
     * legitimate single CSS value needs these, so gradients/shadows/fonts (which
     * use parens/commas/quotes) are preserved. Mirrors designRender.js cssSafe().
     */
    private static function cssSafe(string $value): string
    {
        return trim(str_replace(['\\', ';', '{', '}', '<', '>'], '', $value));
    }

    /**
     * A Glide choice field's "Dropdown panel" controls (data controls in the host
     * schema) → Glide's own --glide-* theming variables, as a declaration
     * string. Mirrors designRender.js glidePanelVars().
     *
     * @param array<string, mixed> $css the item's raw css bag
     */
    private static function glidePanelVars(array $css): string
    {
        $map = [
            'color' => ['--glide-color'],
            'fontSize' => ['--glide-font-size'],
            'fontWeight' => ['--glide-font-weight'],
            'textAlign' => ['--glide-text-align'],
            'glidePanelBg' => ['--glide-bg', '--glide-chip-bg'],
            'glideHover' => ['--glide-hover'],
            'glideActive' => ['--glide-active', '--glide-chip-selected-bg', '--glide-tag-bg'],
            'glideActiveColor' => ['--glide-active-color', '--glide-chip-selected-color', '--glide-tag-color'],
            'glidePlaceholder' => ['--glide-placeholder'],
            'glideFocusBorder' => ['--glide-border-focus', '--glide-chip-selected-border'],
            'glideScrollbar' => ['--glide-scrollbar-thumb'],
            'glidePanelRadius' => ['--glide-radius', '--glide-popup-radius', '--glide-chip-radius'],
        ];
        $decls = [];
        if (! empty($css['fontFamily'])) {
            $decls[] = "--glide-font-family:'" . self::cssSafe((string) $css['fontFamily']) . "',sans-serif";
        }
        foreach ($map as $key => $var) {
            $v = $css[$key] ?? null;
            if ($v === null || $v === '') {
                continue;
            }
            if (in_array($key, ['glidePanelRadius', 'fontSize'], true) && is_numeric($v)) {
                $v .= 'px';
            }
            foreach ($var as $customProperty) {
                $decls[] = $customProperty . ':' . self::cssSafe((string) $v);
            }
        }

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

    /**
     * A range field's "Slider settings" controls (data controls in the host
     * schema) → @cliqthemes/cliq-slider-control's own --cui-slider-* theming
     * variables, as a declaration string. Mirrors designRender.js sliderVars().
     *
     * @param array<string, mixed> $css the item's raw css bag
     */
    private static function sliderVars(array $css): string
    {
        $map = [
            'sliderTrack' => '--cui-slider-track-bg',
            'sliderFill' => '--cui-slider-selection-bg',
            'sliderHandle' => '--cui-slider-handle-bg',
            'sliderHandleBorder' => '--cui-slider-handle-border',
        ];
        $decls = [];
        foreach ($map as $key => $var) {
            $v = $css[$key] ?? null;
            if ($v === null || $v === '') {
                continue;
            }
            $decls[] = $var . ':' . self::cssSafe((string) $v);
        }

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

    /**
     * A marker's pointer tail is driven by ONE "size" number (not separate
     * width/height/gap knobs) — width and the under-the-shape overlap both
     * scale with it at fixed ratios, so "bigger tail" is a single slider.
     * `netOffset` is how far past the container's own bottom edge the tail's
     * visible tip sits (height minus the overlap tucked back under the
     * shape) — the host uses this to push the marker's map-anchor point past
     * the box onto the tail's tip. Mirrors designRender.js tailGeometry().
     *
     * @param mixed $size
     *
     * @return array{height: int, halfWidth: int, overlap: int, netOffset: int}
     */
    private static function tailGeometry($size): array
    {
        $s = min(60, max(6, is_numeric($size) ? (float) $size : 14));
        $height = (int) round($s);
        $halfWidth = (int) round($s * 0.8);
        $overlap = (int) round($s * 0.35);

        return ['height' => $height, 'halfWidth' => $halfWidth, 'overlap' => $overlap, 'netOffset' => $height - $overlap];
    }

    /**
     * Apply cssSafe() to every scalar leaf of a css bag (scalars + box/border
     * sub-arrays) so cssToStyle() can compose already-safe values.
     *
     * @param array<string, mixed> $css
     *
     * @return array<string, mixed>
     */
    private function sanitizeCssBag(array $css): array
    {
        foreach ($css as $k => $v) {
            if (is_array($v)) {
                $css[$k] = $this->sanitizeCssBag($v);
            } elseif (is_scalar($v)) {
                $css[$k] = self::cssSafe((string) $v);
            }
        }

        return $css;
    }

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

    /** Element class list: shared 'cliq-widget' base + type class + scoped id hook
     * (cliq-widget-n-<id>) + optional hover hook. */
    public function nodeClass(string $base, array $node): string
    {
        $css = is_array($node['css'] ?? null)
            ? $node['css']
            : (is_array($node['settings']['css'] ?? null) ? $node['settings']['css'] : []);
        $hover = (! empty($css['hoverBackground']) || ! empty($css['hoverColor'])) ? ' cliq-widget-h-' . $this->nodeId($node) : '';
        $custom = $this->sanitizeClasses($css['cssClass'] ?? '');

        return trim(preg_replace('/\s+/', ' ', 'cliq-widget ' . $base . ' cliq-widget-n-' . $this->nodeId($node) . $hover . ' ' . $custom));
    }

    /** Sanitise a user-supplied class string to space-separated valid CSS identifiers. */
    private function sanitizeClasses($raw): string
    {
        $parts = preg_split('/\s+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $clean = array_filter(array_map(
            static fn ($c) => preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $c),
            $parts
        ));

        return implode(' ', $clean);
    }

    /**
     * Per-type "look" defaults, emitted as scoped (overridable) rules rather than
     * baked into the global .cliq-widget-* classes. Mirrors designRender.js.
     *
     * @return array<string, mixed>
     */
    private function defaultItemCss(array $item): array
    {
        // Single-sourced in DesignRuntimeConfig so PHP/JS renderers cannot drift.
        $defaults = \CliqThemes\StoreLocator\Support\DesignRuntimeConfig::itemDefaults();

        $type = (string) ($item['type'] ?? 'field');
        if ($type === 'heading') {
            return $defaults['heading'] ?? [];
        }
        // submit/geocode are placeable BUTTON fields (see buildTypes.js) — same
        // default look as a type==='button' item.
        if ($type === 'button' || $type === 'submit' || $type === 'geocode') {
            return $defaults['button'] ?? [];
        }
        // Search-form input types (everything that isn't a button/heading/media
        // field) get the shared 'input' default look — same mechanism as
        // heading/button/logo, not a hardcoded stylesheet rule per field type.
        // 'text' is unambiguous for every type here EXCEPT itself: a layout
        // design's plain text LABEL item is ALSO type==='text' (see
        // renderItem() below) — a real collision (list_item/infobox/marker
        // designs use it constantly), disambiguated via `name`: a search-form
        // field always has one from creation (HTML form-field convention), a
        // layout text/heading/field item never does.
        if (in_array($type, ['email', 'tel', 'number', 'textarea', 'date', 'select', 'chips', 'accordion', 'popup', 'options_list', 'checkbox', 'radio', 'toggle'], true)) {
            return $defaults['input'] ?? [];
        }
        if ($type === 'text' && array_key_exists('name', $item)) {
            return $defaults['input'] ?? [];
        }
        if ($type === 'field') {
            $fmt = $this->tokenFormat((string) ($item['token'] ?? ''));
            if ($fmt === 'button') {
                return $defaults['button'] ?? [];
            }
            if ($fmt === 'media') {
                return $defaults['logo'] ?? [];
            }
        }

        return [];
    }

    /** A token's metadata format (e.g. 'button' | 'media'), or '' when unknown. */
    private function tokenFormat(string $token): string
    {
        foreach ($this->tokens as $t) {
            if (($t['value'] ?? null) === $token) {
                return (string) ($t['format'] ?? '');
            }
        }

        return '';
    }

    private function isButtonItem(array $item): bool
    {
        $type = (string) ($item['type'] ?? 'field');
        if (in_array($type, ['button', 'submit', 'geocode'], true)) {
            return true;
        }

        return $type === 'field' && $this->tokenFormat((string) ($item['token'] ?? '')) === 'button';
    }

    /** Structural cell layout: span + width (auto=hug / full=stretch) + alignment,
     * OR — when `overlay` is set — absolute positioning over a sibling cell in the
     * same column (badge over hero image, etc). Mirrors designRender.js. */
    private function cellLayoutStyle(array $item): string
    {
        $css = is_array($item['css'] ?? null) ? $item['css'] : [];

        if (! empty($css['overlay'])) {
            // grid-column:auto cancels .cliq-widget-cell's base "span 6" fallback,
            // which would otherwise still snap this abspos item to a grid track
            // instead of the given insets (see design.scss / the JS twin).
            $s = 'position:absolute;grid-column:auto;';
            foreach (['Top' => 'top', 'Right' => 'right', 'Bottom' => 'bottom', 'Left' => 'left'] as $key => $prop) {
                $v = $css['inset' . $key] ?? '';
                if ($v !== '' && $v !== null) {
                    $val = is_numeric($v) ? $v . 'px' : (string) $v;
                    $s .= $prop . ':' . self::cssSafe($val) . ';';
                }
            }
            if (isset($css['zIndex']) && $css['zIndex'] !== '') {
                $s .= 'z-index:' . (int) $css['zIndex'] . ';';
            }

            return $s;
        }

        $s = 'grid-column:span ' . (int) ($item['colSpan'] ?? 12) . ';';
        if (($css['fit'] ?? 'full') === 'auto') {
            $align = (string) ($css['align'] ?? 'left');
            $s .= 'justify-self:' . ($align === 'center' ? 'center' : ($align === 'right' ? 'end' : 'start')) . ';';
        } else {
            $s .= 'justify-self:stretch;';
        }
		if (isset($css['cellJustifyContent']) && $css['cellJustifyContent'] !== '') {
			$s .= 'justify-content:' . self::cssSafe((string) $css['cellJustifyContent']) . ';';
		}
		if (isset($css['cellAlignItems']) && $css['cellAlignItems'] !== '') {
			$s .= 'align-items:' . self::cssSafe((string) $css['cellAlignItems']) . ';';
		}

        return $s;
    }

    /**
     * A tint is a real overlay layer (::after) painted ON TOP of the item's own
     * content — not a `background` sitting behind it — so it visibly washes a
     * hero image (an <img>'s own pixels would otherwise hide a plain
     * background). Mirrors designRender.js tintOverlayCss(). The cell wrapper
     * is reused as the positioning/clipping context; `position` is skipped when
     * the item is in overlay-position mode (cellLayoutStyle already made it
     * absolute — still a valid containing block).
     *
     * @param array<string, mixed> $item
     * @param array<string, mixed> $mergedCss the item's EFFECTIVE css (type
     *        defaults + overrides, same bag buildCss() already computed for the
     *        item node) so a rounded hero image's default border-radius carries
     *        onto the overlay too — otherwise a square ::after visibly pokes
     *        past the image's rounded corners once opaque enough.
     *
     * @return array<int, string>
     */
    private function tintOverlayCss(array $item, string $scope, array $mergedCss): array
    {
        $tint = (string) ($mergedCss['tint'] ?? '');
        $tintHover = (string) ($mergedCss['tintHover'] ?? '');
        // Image-hover effects: zoom is a transform; the rest compose ONE
        // filter() list on hover. The hover* toggles are the ONLY source — an
        // unchecked toggle means no effect. Mirrors designRender.js hoverEffectsOf().
        $hoverFilters = [
            'hoverBlur' => 'blur(4px)',
            'hoverGrayscale' => 'grayscale(1)',
            'hoverBrightness' => 'brightness(1.12)',
            'hoverSepia' => 'sepia(1)',
            'hoverSaturate' => 'saturate(1.4)',
        ];
        $zoom = ! empty($mergedCss['hoverZoom']);
        $filters = [];
        foreach ($hoverFilters as $key => $filter) {
            if (! empty($mergedCss[$key])) {
                $filters[] = $filter;
            }
        }
        if ($tint === '' && $tintHover === '' && ! $zoom && $filters === []) {
            return [];
        }

        $cellSel = $scope . ' .cliq-widget-cl-' . $this->nodeId($item);
        $borderRadius = $mergedCss['borderRadius'] ?? '';
        $radiusValue = $borderRadius !== '' && $borderRadius !== null
            ? self::cssSafe(is_numeric($borderRadius) ? $borderRadius . 'px' : (string) $borderRadius)
            : '';

        // Positioning/clipping box shared by both features: the cell wrapper
        // becomes the ::after's containing block and clips the zoomed image.
        $boxDecls = [];
        if (empty($mergedCss['overlay'])) {
            $boxDecls[] = 'position:relative';
        }
        $boxDecls[] = 'overflow:hidden';
        if ($radiusValue !== '') {
            $boxDecls[] = 'border-radius:' . $radiusValue;
        }
        $boxRule = $cellSel . '{' . implode(';', $boxDecls) . '}';

        $rules = [];

        if ($tint !== '' || $tintHover !== '') {
            $afterDecls = ['content:""', 'position:absolute', 'inset:0', 'pointer-events:none', 'z-index:1'];
            if ($radiusValue !== '') {
                $afterDecls[] = 'border-radius:' . $radiusValue;
            }
            $afterDecls[] = 'transition:background var(--cliq-transition-duration, .4s) var(--cliq-transition-easing, ease-in-out)';
            $afterDecls[] = 'background:' . self::cssSafe($tint !== '' ? $tint : 'transparent');

            $rules[] = $boxRule;
            $rules[] = $cellSel . '::after{' . implode(';', $afterDecls) . '}';
            if ($tintHover !== '') {
                $rules[] = $cellSel . ':hover::after{background:' . self::cssSafe($tintHover) . '}';
            }
        }
        if ($zoom || $filters !== []) {
            // No per-effect transition — the BASE layer (.cliq-widget in
            // design.scss) transitions transform/filter with the shared
            // --cliq-transition-* timing.
            $rules[] = $boxRule;
            $hoverDecls = [];
            if ($zoom) {
                $hoverDecls[] = 'transform:scale(1.08)';
            }
            if ($filters !== []) {
                $hoverDecls[] = 'filter:' . implode(' ', $filters);
            }
            $rules[] = $cellSel . ':hover .cliq-widget-n-' . $this->nodeId($item) . '{' . implode(';', $hoverDecls) . '}';
        }

        return $rules;
    }

    /** @param array<string, mixed> $row */
    private function renderRow(array $row, array $store): string
    {
        // No inline styles — all layout (gap/span/justify) lives in the scoped
        // stylesheet (see buildCss), keyed by the cliq-widget-n-/cl- hooks.
        $html = '<div class="' . esc_attr($this->nodeClass('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->nodeClass('cliq-widget-col', $col)) . '">';
            foreach ((is_array($col['items'] ?? null) ? $col['items'] : []) as $item) {
                $item = is_array($item) ? $item : [];
                $cellClass = 'cliq-widget cliq-widget-cell cliq-widget-cl-' . $this->nodeId($item);
                $html .= '<div class="' . esc_attr($cellClass) . '">'
                    . $this->renderItem($item, $store)
                    . '</div>';
            }
            $html .= '</div>';
        }
        $html .= '</div>';

        return $html;
    }

    /** @param array<string, mixed> $item */
    private function renderItem(array $item, array $store): string
    {
        $type = (string) ($item['type'] ?? 'field');
        $link = $this->nodeLink($item, $store);

        if ($type === 'icon') {
            $glyph = $this->iconGlyph($item, 24);
            return $this->wrapItemLink('<span class="' . esc_attr($this->nodeClass('cliq-widget-icon' . ($glyph === '' ? ' cliq-widget-icon--empty' : ''), $item)) . '">' . $glyph . '</span>', $link);
        }
        if ($type === 'button') {
            $href = (string) ($link['href'] ?? ($item['url'] ?? ''));
            $target = (string) ($link['target'] ?? '');
            return '<a class="' . esc_attr($this->nodeClass('cliq-widget-btn', $item)) . '"'
                . ($href !== '' ? ' href="' . esc_url($href) . '"' : '')
                . ($target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '') . '>'
                . $this->withIcon($item, esc_html((string) ($item['text'] ?? __('Button', 'cliq-store-locator')))) . '</a>';
        }
        if ($type === 'heading') {
            return $this->wrapItemLink('<span class="' . esc_attr($this->nodeClass('cliq-widget-text cliq-widget-text--heading', $item)) . '">'
                . $this->withIcon($item, esc_html($this->interpolateTokens((string) ($item['text'] ?? ''), $store))) . '</span>', $link);
        }
        if ($type === 'text') {
            return $this->wrapItemLink('<span class="' . esc_attr($this->nodeClass('cliq-widget-text', $item)) . '">'
                . $this->withIcon($item, esc_html($this->interpolateTokens((string) ($item['text'] ?? ''), $store))) . '</span>', $link);
        }

        // Dynamic store field.
        $token = (string) ($item['token'] ?? '');
        $val = $this->tokenValue($token, $store, $item);

        if (is_array($val) && ($val['type'] ?? '') === 'logo') {
            $src = (string) ($val['src'] ?? '');
            if ($src === '') {
                $initial = strtoupper(substr((string) ($val['name'] ?? '?'), 0, 1));
                return $this->wrapItemLink('<div class="' . esc_attr($this->nodeClass('cliq-widget-logo cliq-widget-logo--ph', $item)) . '">' . esc_html($initial) . '</div>', $link);
            }
            // item.css.imageSize (the "Image size" control, media tokens only) picks
            // which WP-registered size variant to display — same normaliseImage()
            // machinery the Gallery field already uses for its own imageSize.
            $imageSize = (string) ($item['css']['imageSize'] ?? '') ?: 'large';
            $sized = $this->normaliseImage($val['raw'] ?? $src, $imageSize);
            $displaySrc = is_array($sized) ? ($sized['thumb'] ?? $src) : $src;
            return $this->wrapItemLink('<img class="' . esc_attr($this->nodeClass('cliq-widget-logo', $item)) . '" src="' . esc_url($displaySrc) . '" alt="' . esc_attr((string) ($val['name'] ?? '')) . '" />', $link);
        }
        /**
         * Render an item type this renderer does not know.
         *
         * @param string|null          $html
         * @param mixed                $val   the resolved token value; an array for
         * @param array<string, mixed> $item  the placing item
         * @param string|null          $link  an href the item is wrapped in
         */
        $custom = apply_filters('cliq_sl_render_item_value', null, $val, $item, $link, $this);
        if (is_string($custom)) {
            return $this->wrapItemLink($custom, $link);
        }
        if (is_array($val) && ($val['type'] ?? '') === 'html') {
            // Rich-text field: already sanitised in tokenValue(); emit verbatim.
            $richHtml = (string) ($val['html'] ?? '');
            if ($link !== null) {
                // Avoid invalid nested anchors when the WYSIWYG value already
                // contains links and the builder links the entire field.
                $richHtml = (string) preg_replace('/<\/?a\b[^>]*>/i', '', $richHtml);
            }
            return $this->wrapItemLink('<div class="' . esc_attr($this->nodeClass('cliq-widget-text cliq-widget-html', $item)) . '">'
                . $richHtml . '</div>', $link);
        }
        if (is_array($val) && ($val['type'] ?? '') === 'button') {
            if ($link !== null) {
                return '<a class="' . esc_attr($this->nodeClass('cliq-widget-btn', $item)) . '" href="' . esc_url($link['href']) . '"'
                    . (($link['target'] ?? '') === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '') . '>'
                    . $this->withIcon($item, esc_html((string) ($val['label'] ?? ''))) . '</a>';
            }
            return '<button type="button" class="' . esc_attr($this->nodeClass('cliq-widget-btn', $item)) . '">'
                . $this->withIcon($item, esc_html((string) ($val['label'] ?? ''))) . '</button>';
        }
        if (is_array($val) && ($val['type'] ?? '') === 'bool') {
            $on = ! empty($val['on']);
            $text = $on ? (string) ($item['toggleOnText'] ?? '') : (string) ($item['toggleOffText'] ?? '');
            if ($text === '') {
                // Nothing configured for this state (typically "off" with no
                // off-text) — render nothing at all, not an empty badge/icon.
                return '';
            }
            // The icon decorates the "on"/true state only (e.g. a checkmark on a
            // "Featured" badge) — showing it for "off" too would leave a bare
            // icon-only badge on every non-featured card.
            $content = $on ? $this->withIcon($item, esc_html($text)) : esc_html($text);

            return $this->wrapItemLink('<span class="' . esc_attr($this->nodeClass('cliq-widget-text', $item)) . '">' . $content . '</span>', $link);
        }
        if (is_array($val) && ($val['type'] ?? '') === 'open_status') {
            if (! array_key_exists('open', $val) || $val['open'] === null) {
                return '';
            }
            $open = (bool) $val['open'];
            $label = trim((string) ($open ? ($item['openText'] ?? '') : ($item['closedText'] ?? '')));
            if ($label === '') {
                $label = $open ? __('Open now', 'cliq-store-locator') : __('Closed', 'cliq-store-locator');
            }
            $class = $this->nodeClass('cliq-widget-text cliq-widget-open-status', $item) . ($open ? ' is-open' : ' is-closed');

            return $this->wrapItemLink('<span class="' . esc_attr($class) . '">' . $this->withIcon($item, esc_html($label)) . '</span>', $link);
        }

        // Do not render an authored prefix/suffix without its dynamic value.
        // Distance is intentionally empty until a search origin is known; the
        // alternative would produce broken fragments such as "About away".
        if ($val === null || $val === '') {
            return '';
        }

        // item.prefix/suffix wrap the resolved value (e.g. "(" + count + " stores)")
        // — JS twin: designRender.js's LayoutItem default branch.
        $displayLabel = trim((string) ($item['displayLabel'] ?? ''));
        $text = (string) ($item['prefix'] ?? '')
            . ($displayLabel !== '' ? $displayLabel : (string) $val)
            . (string) ($item['suffix'] ?? '');

        return $this->wrapItemLink('<span class="' . esc_attr($this->nodeClass('cliq-widget-text', $item)) . '">' . $this->withIcon($item, esc_html($text)) . '</span>', $link);
    }

    /** Replace {token} merge tags in authored text with resolved store values. */
    private function interpolateTokens(string $template, array $store): string
    {
        $missing = false;
        $rendered = (string) preg_replace_callback(
            '/\{([a-zA-Z0-9_.-]+)\}/',
            function (array $match) use ($store, &$missing): string {
                $value = $this->tokenValue((string) $match[1], $store);
                if (! is_scalar($value) || $value === '') {
                    $missing = true;

                    return '';
                }

                return (string) $value;
            },
            $template
        );

        return $missing ? '' : $rendered;
    }

    /** @return array{href:string,target:string}|null */
    private function nodeLink(array $node, array $store): ?array
    {
        $css = is_array($node['css'] ?? null) ? $node['css'] : [];
        $type = (string) ($css['linkType'] ?? '');
        // Dynamic per-store targets let ANY element (a button, an image, the whole
        // card) point at that store's own website/phone — not just its detail page.
        switch ($type) {
            case 'details':
                $href = (string) ($store['detail_url'] ?? $store['url'] ?? $store['permalink'] ?? '');
                break;
            case 'website':
                $href = trim((string) ($store['website'] ?? ''));
                if ($href !== '' && ! preg_match('#^https?://#i', $href)) {
                    $href = 'https://' . ltrim($href, '/');
                }
                break;
            case 'email':
                $email = sanitize_email((string) ($store['email'] ?? ''));

                return $email === ''
                    ? null
                    : ['href' => 'mailto:' . $email, 'target' => ''];
            case 'phone':
                $phone = trim((string) ($store['phone'] ?? ''));
                $digits = (string) preg_replace('/[^0-9+]/', '', $phone);

                return $digits === ''
                    ? null
                    : ['href' => 'tel:' . $digits, 'target' => ''];
            case 'custom':
                $href = trim((string) ($css['linkUrl'] ?? ''));
                break;
            default:
                $href = '';
        }
        $href = esc_url($href);
        if ($href === '') {
            return null;
        }

        return ['href' => $href, 'target' => ! empty($css['linkNewTab']) ? '_blank' : ''];
    }

    /** @param array{href:string,target:string}|null $link */
    private function wrapItemLink(string $html, ?array $link): string
    {
        if ($link === null) {
            return $html;
        }
        return '<a class="cliq-widget cliq-widget-link" href="' . esc_url($link['href']) . '"'
            . ($link['target'] === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '') . '>' . $html . '</a>';
    }

    /** @param array{href:string,target:string}|null $link */
    private function containerLinkAttrs(?array $link): string
    {
        if ($link === null) {
            return '';
        }
        return ' role="link" tabindex="0" data-cliq-link="' . esc_url($link['href']) . '"'
            . ($link['target'] === '_blank' ? ' data-cliq-link-target="_blank"' : '');
    }

    /** Inline SVG for an item's lucide icon. Size from css.iconSize; colour is
     * applied by the scoped `svg` rule (css-driven via the cssMap selector), so the
     * glyph renders with currentColor. */
    private function iconGlyph(array $item, int $default): string
    {
        $name = (string) ($item['icon'] ?? '');
        if ($name === '') {
            return '';
        }
        $css = is_array($item['css'] ?? null) ? $item['css'] : [];
        $size = $default;
        if (! empty($css['iconSize'])) {
            $n = (int) preg_replace('/[^0-9]/', '', (string) $css['iconSize']);
            $size = $n > 0 ? $n : $default;
        }

        return DesignIcons::svg($name, $size);
    }

    /** Prepend/append an item's icon to its content (matches the JS WithIcon). */
    private function withIcon(array $item, string $content): string
    {
        $glyph = $this->iconGlyph($item, 15);
        if ($glyph === '') {
            return $content;
        }

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


    /**
     * Normalise a stored media value into {full, thumb, w, h, alt}. `full` is the
     * original (for the lightbox); `thumb` is the requested display size, falling
     * back to the next-largest available, then the original.
     *
     * @param mixed  $img
     * @param string $imageSize WP size name for the visible image (e.g. 'large')
     * @return array<string, mixed>|null
     */
    public function normaliseImage($img, string $imageSize = 'large'): ?array
    {
        if (is_string($img)) {
            $img = ['url' => $img];
        }
        if (! is_array($img)) {
            return null;
        }
        $full = (string) ($img['url'] ?? $img['source_url'] ?? '');
        if ($full === '') {
            return null;
        }
        $sizes = is_array($img['sizes'] ?? null) ? $img['sizes'] : [];
        $thumb = '';
        if ($imageSize !== 'full' && $imageSize !== '') {
            foreach ([$imageSize, 'large', 'medium_large', 'medium'] as $size) {
                if (! empty($sizes[$size]['url'])) {
                    $thumb = (string) $sizes[$size]['url'];
                    break;
                }
            }
        }

        return [
            'full' => $full,
            'thumb' => $thumb !== '' ? $thumb : $full,
            'w' => (int) ($img['width'] ?? 1600),
            'h' => (int) ($img['height'] ?? 1200),
            'alt' => (string) ($img['alt'] ?? ''),
        ];
    }

    /* ---- token resolution (ported from designRender.js tokenValue) ---- */

    /** @param array<string, mixed> $store @return mixed */
    private function tokenValue(string $token, array $store, ?array $item = null)
    {
        $meta = [];
        foreach ($this->tokens as $t) {
            if (($t['value'] ?? null) === $token) {
                $meta = $t;
                break;
            }
        }
        $format = (string) ($meta['format'] ?? 'text');
        $raw = $this->readPath($store, $meta['source'] ?? null);
        if ($raw === null) {
            $raw = $store[$token] ?? null;
        }

        // Generic content formats only. Domain formats (address / schedule / hours /
        // distance / rating / category) are supplied by host callbacks on the
        // `cliq_sl_token_value` filter — the PHP twin of the JS `cliqui.token_value`
        // hook ({@see TokenFormats}) — so this renderer carries no store knowledge.
        switch ($format) {
            case 'media':
                // Carry the resolved media object (not just its URL) as `raw` so
                // renderItem() — which has the placing item's chosen
                // item.css.imageSize in scope, unlike this token-only method —
                // can pick a specific WP size variant via normaliseImage().
                $mediaRaw = $raw;
                $src = $this->mediaUrl($raw);
                if ($src === '' && ! empty($meta['fallback'])) {
                    $mediaRaw = $this->readPath($store, $meta['fallback']);
                    $src = $this->mediaUrl($mediaRaw);
                }
                $value = ['type' => 'logo', 'src' => $src, 'raw' => $mediaRaw, 'name' => (string) ($store['name'] ?? '')];
                break;
            case 'gallery':
                if (is_string($raw)) {
                    $decoded = json_decode($raw, true);
                    $raw = is_array($decoded) ? $decoded : [];
                }
                $value = ['type' => 'gallery', 'images' => is_array($raw) ? $raw : []];
                break;
            case 'button':
                $customLabel = trim((string) ($item['buttonLabel'] ?? ''));
                $value = [
                    'type' => 'button',
                    'label' => $customLabel !== '' ? $customLabel : (string) ($meta['label'] ?? $token),
                ];
                break;
            case 'html':
            case 'richtext':
                $value = ['type' => 'html', 'html' => wp_kses_post(is_scalar($raw) ? (string) $raw : '')];
                break;
            case 'toggle':
                // A typed marker, not the final text — the layout ITEM (not this
                // token-only context) owns the "text when true"/"text when false"
                // wording (item.toggleOnText/toggleOffText, since the same token
                // can be placed on multiple designs wanting different wording).
                // Stored value can be "1"/"0"/""/a real bool depending on write
                // path (admin form vs. import) — loose-truthy check handles all.
                $value = ['type' => 'bool', 'on' => ! empty($raw) && $raw !== '0'];
                break;
            default:
                // A multi-value field (checkboxes / multiselect) arrives as an array
                // or a JSON-encoded list string; render comma-joined scalars instead
                // of dumping `["dining"]` verbatim.
                if (is_string($raw) && isset($raw[0]) && $raw[0] === '[') {
                    $decoded = json_decode($raw, true);
                    if (is_array($decoded)) {
                        $raw = $decoded;
                    }
                }
                if (is_array($raw)) {
                    if (array_is_list($raw)) {
                        $parts = array_map(
                            static fn ($x) => is_array($x) ? (string) ($x['name'] ?? $x['label'] ?? '') : (string) $x,
                            $raw
                        );
                        $value = implode(', ', array_filter($parts, static fn ($s) => $s !== ''));
                    } else {
                        $value = (string) ($raw['name'] ?? $raw['label'] ?? $this->mediaUrl($raw));
                    }
                } else {
                    $value = $raw === null ? '—' : (string) $raw;
                }
        }

        /**
         * Resolve/override a token's rendered value — `cliq_sl_token_value`, the
         * PHP twin of the JS `cliqui.token_value` filter. Domain formats (address,
         * hours, distance, …) and extensions hook here instead of being baked in.
         *
         * @param mixed                $value Generic value computed above.
         * @param array<string, mixed> $ctx   token / record / meta / format / raw / tokens.
         */
        return apply_filters('cliq_sl_token_value', $value, [
            'token' => $token,
            'record' => $store,
            'meta' => $meta,
            'format' => $format,
            'raw' => $raw,
            'tokens' => $this->tokens,
            'item' => $item,
        ]);
    }

    /** @param array<string, mixed> $store @return mixed */
    private function readPath(array $store, $path)
    {
        if (! is_string($path) || $path === '') {
            return null;
        }
        if (strpos($path, '.') !== false) {
            // Nested (live payload) first, then the flattened top-level key (admin
            // record) — so the same design resolves in the preview and on the site.
            [$a, $b] = explode('.', $path, 2);
            $nested = isset($store[$a]) && is_array($store[$a]) ? ($store[$a][$b] ?? null) : null;
            return $nested ?? ($store[$b] ?? null);
        }
        return $store[$path] ?? null;
    }

    /** @param mixed $v */
    private function mediaUrl($v): string
    {
        if (is_string($v)) {
            return $v;
        }
        if (is_array($v)) {
            return (string) ($v['url'] ?? $v['source_url'] ?? '');
        }
        return '';
    }

    /**
     * Domain token formats (address / opening hours / distance / rating / …) live
     * in {@see TokenFormats}, registered on the `cliq_sl_token_value` filter — the
     * PHP twin of the JS `cliqui.token_value` hook (resources/shared/tokenFormats.js).
     */

    /** Map a stored css object to an inline (kebab-case) style string. */
    private function cssToStyle(array $css): string
    {
        // Sanitise every authored value up-front so the many emit points below
        // cannot break out of its generated rule (see cssSafe).
        $css = $this->sanitizeCssBag($css);
        $s = '';
        $dim = static fn ($v) => self::cssSafe(is_numeric($v) ? $v . 'px' : (string) $v);
        $box = static function (string $prop, $v) use (&$s, $dim): void {
            if ($v === null || $v === '') {
                return;
            }
            if (is_array($v)) {
                foreach (['top', 'right', 'bottom', 'left'] as $side) {
                    if (! empty($v[$side])) {
                        $s .= $prop . '-' . $side . ':' . $dim($v[$side]) . ';';
                    }
                }
            } else {
                $s .= $prop . ':' . $dim($v) . ';';
            }
        };

        if (! empty($css['background'])) {
            $s .= 'background:' . self::cssSafe((string) $css['background']) . ';';
        }
        if (! empty($css['color'])) {
            $s .= 'color:' . self::cssSafe((string) $css['color']) . ';';
        }
        if (! empty($css['textAlign'])) {
            $s .= 'text-align:' . self::cssSafe((string) $css['textAlign']) . ';';
        }
        if (! empty($css['fontWeight'])) {
            $s .= 'font-weight:' . self::cssSafe((string) $css['fontWeight']) . ';';
        }
        if (! empty($css['fontFamily'])) {
            $s .= "font-family:'" . self::cssSafe((string) $css['fontFamily']) . "', sans-serif;";
        }
        if (! empty($css['fontSize'])) {
            $s .= 'font-size:' . $dim($css['fontSize']) . ';';
        }
        // Leading and tracking: the difference between "big text" and typography.
        // Unitless line-height values (1.05) are kept as ratios; anything with a
        // unit passes through. Tracking accepts negative values (em/px).
        if (isset($css['lineHeight']) && $css['lineHeight'] !== '') {
            $lh = (string) $css['lineHeight'];
            $s .= 'line-height:' . self::cssSafe(is_numeric($lh) ? $lh : $lh) . ';';
        }
        if (isset($css['letterSpacing']) && $css['letterSpacing'] !== '') {
            $ls = (string) $css['letterSpacing'];
            $s .= 'letter-spacing:' . self::cssSafe(is_numeric($ls) ? $ls . 'px' : $ls) . ';';
        }
        if (! empty($css['width'])) {
            $s .= 'width:' . $dim($css['width']) . ';';
        }
        if (! empty($css['height'])) {
            $s .= 'height:' . $dim($css['height']) . ';';
        }
        if (! empty($css['maxWidth'])) {
            $s .= 'max-width:' . $dim($css['maxWidth']) . ';';
        }
        if (! empty($css['maxHeight'])) {
            $s .= 'max-height:' . $dim($css['maxHeight']) . ';overflow:auto;';
        }
        $box('padding', $css['padding'] ?? null);
        $box('margin', $css['margin'] ?? null);
        if (isset($css['borderRadius']) && $css['borderRadius'] !== '') {
            $s .= 'border-radius:' . $dim($css['borderRadius']) . ';';
        }
        if (is_array($css['border'] ?? null) && (! empty($css['border']['width']) || ! empty($css['border']['color']))) {
            $s .= 'border:' . self::cssSafe((string) ($css['border']['width'] ?? '1px')) . ' ' . self::cssSafe((string) ($css['border']['style'] ?? 'solid')) . ' ' . self::cssSafe((string) ($css['border']['color'] ?? '#e2e8f0')) . ';';
        } elseif (isset($css['borderWidth']) && $css['borderWidth'] !== '') {
            $s .= 'border:' . $dim($css['borderWidth']) . ' solid ' . self::cssSafe((string) ($css['borderColor'] ?? '#e2e8f0')) . ';';
        }
        if (! empty($css['shadowX']) || ! empty($css['shadowY']) || ! empty($css['shadowBlur']) || ! empty($css['shadowSpread'])) {
            $s .= 'box-shadow:' . $dim($css['shadowX'] ?? 0) . ' ' . $dim($css['shadowY'] ?? 0) . ' ' . $dim($css['shadowBlur'] ?? 0) . ' ' . $dim($css['shadowSpread'] ?? 0) . ' ' . self::cssSafe((string) ($css['shadowColor'] ?? 'rgba(15,23,42,.15)')) . ';';
        }
        if (! empty($css['overflow'])) {
            $s .= 'overflow:' . self::cssSafe((string) $css['overflow']) . ';';
        }
        if (! empty($css['objectFit'])) {
            $s .= 'object-fit:' . self::cssSafe((string) $css['objectFit']) . ';';
        }

        return $s;
    }
}
