<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Frontend;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\Data\Definition\StoreProviderDefinition;
use CliqThemes\StoreLocator\Layouts\BundledLocatorPreset;
use CliqThemes\StoreLocator\Layouts\BundledLocatorBuilder;
use CliqThemes\StoreLocator\I18n\StringTranslator;
use CliqThemes\StoreLocator\Integrations\IntegrationManager;
use CliqThemes\StoreLocator\Layouts\LocatorLayouts;
use CliqThemes\StoreLocator\Integrations\StoreDetailSeo;
use CliqThemes\StoreLocator\REST\PublicController;
use CliqThemes\StoreLocator\Support\DesignRuntimeConfig;
use CliqThemes\StoreLocator\Support\DesignTokens;
use CliqThemes\StoreLocator\Support\FrontendAssetRegistry;
use CliqThemes\StoreLocator\Support\MapStyleResolver;
use CliqThemes\StoreLocator\Support\SeoSchemaService;
use CliqThemes\StoreLocator\Support\SettingsService;

/**
 * Visitor-facing runtime: a `[cliq_store_locator id=N]` shortcode and a matching
 * Gutenberg block, both mounting the cliq-ui frontend bundle against a saved
 * locator template. The map library (Leaflet or Google Maps) is chosen from
 * Settings; the bundle reads site config from `window.CLIQ_SL`.
 */
final class FrontendModule
{
    // Keep the historic handle for the Locator entry; standalone placements
    // receive their own entries and all three depend on the small shared runtime.
    private const HANDLE = 'cliq-sl-frontend';
    private const SHARED_HANDLE = 'cliq-sl-frontend-runtime';
    private const STYLE_HANDLE = 'cliq-sl-frontend';
    private const THEME_FONT_HANDLE = 'cliq-sl-theme-font';
    private const LEAFLET = 'cliq-sl-leaflet';
    private const LEAFLET_CLUSTER = 'cliq-sl-leaflet-cluster';
    private const GOOGLE = 'cliq-sl-google-maps';
    private const GOOGLE_CLUSTER = 'cliq-sl-google-cluster';
    private const GLIDE = 'cliq-sl-glide-dropdown';
    // SliderControl imports its vendored CSS as a module. wp-scripts emits that
    // small stylesheet per placement entry, separately from style-shared.css.
    private const SLIDER_VENDOR = 'cliq-sl-slider-vendor';
    private const EDITOR_HANDLE = 'cliq-sl-block-editor';

    private string $pluginFile;

    /** Cached store detail payload for the current request (+ resolved flag). */
    private ?array $detailStore = null;
    private bool $detailStoreResolved = false;

    /** SEO-plugin integrations (Yoast / Rank Math / SEOPress). */
    private ?IntegrationManager $integrations = null;

    public function __construct(string $pluginFile)
    {
        $this->pluginFile = $pluginFile;
    }

    public function register(): void
    {
        add_action('wp_enqueue_scripts', [$this, 'registerAssets']);

        // register() already runs on `init` (via Plugin::bootUiLibrary), so the
        // shortcode/block must be registered DIRECTLY — a nested add_action('init')
        // would be queued too late to fire this request (same reason as the
        // rewrite rule below). This is why cliq_store_locator/the block silently
        // never registered before.
        $this->registerShortcode();
        $this->registerBlock();

        // Standalone lead / registration forms, embeddable on any page (mirrors
        // the locator's shortcode + block treatment). Registered directly for
        // the same reason as above — register() already runs on `init`.
        /**
         * Register placements — shortcodes and blocks — for a feature of your own.
         */
        do_action('cliq_sl_register_placements');

        // Store detail page: a rewrite maps /{base-page}/{store-slug}/ onto the
        // assigned base page with a `cliq_store` query var, where the
        // [cliq_store_detail] shortcode/block renders the store. register() is
        // already invoked on `init`, so register the rule directly (a nested
        // init action would be added too late to fire this request).
        $this->registerStoreDetail();
        add_filter('query_vars', static function (array $vars): array {
            $vars[] = 'cliq_store';
            return $vars;
        });
        add_shortcode('cliq_store_detail', [$this, 'renderStoreDetail']);
        add_filter('the_content', [$this, 'injectStoreDetailContent'], 9);
        add_action('wp_head', [$this, 'renderStoreDetailSeo']);
        add_filter('document_title_parts', [$this, 'storeDetailTitle']);
        add_action('template_redirect', [$this, 'prepareStoreDetailHead']);

        // SEO-plugin integrations (Yoast / Rank Math / SEOPress): each self-detects
        // and, when active, points its title/OG/canonical/sitemap at the store.
        // They share our cached store resolver so no extra query / view is logged.
        $seo = new StoreDetailSeo(fn (): ?array => $this->currentDetailStore());
        $this->integrations = new IntegrationManager($seo);
        $this->integrations->boot();

        // Rewrite rules depend on the assigned page, so re-flush when settings change.
        add_action('update_option_' . SettingsService::OPTION, [$this, 'flushStoreDetailRules']);
    }

    /**
     * Handles a feature's own bundles depend on.
     *
     * The shared runtime must load before any placement's script, and the base
     * stylesheet before any placement's rules.
     */
    public static function sharedScriptHandle(): string
    {
        return self::SHARED_HANDLE;
    }

    public static function styleHandle(): string
    {
        return self::STYLE_HANDLE;
    }

    /** The Google Maps handle, for a placement that needs Places autocomplete. */
    public static function googleMapsHandle(): string
    {
        return self::GOOGLE;
    }

    /** Register the rewrite rule for the configured store detail base page. */
    public function registerStoreDetail(): void
    {
        $pageId = (int) SettingsService::get('store_detail_page', 0);
        if ($pageId <= 0 || ! SettingsService::get('store_detail_pages', true)) {
            return;
        }

        $uri = get_page_uri($pageId);
        if (! is_string($uri) || $uri === '') {
            return;
        }

        add_rewrite_rule(
            '^' . preg_quote($uri, '#') . '/([^/]+)/?$',
            'index.php?page_id=' . $pageId . '&cliq_store=$matches[1]',
            'top'
        );
    }

    /** Flush rewrite rules (called when settings change + on activation). */
    public function flushStoreDetailRules(): void
    {
        $this->registerStoreDetail();
        flush_rewrite_rules(false);
    }

    /** Permalink to a store's detail page, or '' when the feature is unconfigured. */
    public static function storePermalink(string $slug): string
    {
        $pageId = (int) SettingsService::get('store_detail_page', 0);
        if ($pageId <= 0 || ! SettingsService::get('store_detail_pages', true) || $slug === '') {
            return '';
        }

        return trailingslashit(get_permalink($pageId)) . rawurlencode($slug) . '/';
    }

    /** Register (not enqueue) the runtime + Leaflet so render callbacks can enqueue on demand. */
    public function registerAssets(): void
    {
        // The visitor runtime is the plugin's OWN bundle (built against the cliq-ui
        // framework via the @cliqui alias) — not a cliq-ui asset.
        $buildUrl = plugins_url('resources/frontend/build', $this->pluginFile);
        $buildDir = plugin_dir_path($this->pluginFile) . 'resources/frontend/build';
        $sharedAsset = is_readable($buildDir . '/shared.asset.php')
            ? require $buildDir . '/shared.asset.php'
            : ['dependencies' => ['wp-element', 'wp-dom-ready', 'wp-i18n'], 'version' => CLIQ_SL_VERSION];
        $locatorAsset = is_readable($buildDir . '/locator.asset.php') ? require $buildDir . '/locator.asset.php' : ['dependencies' => [], 'version' => CLIQ_SL_VERSION];
        // wp-scripts' asset hash primarily follows the JavaScript entry. A
        // CSS-only rebuild can therefore retain the same public URL and leave
        // browsers/CDNs serving stale rules. Version each stylesheet from its
        // own file modification time so builder styling changes invalidate it.
        $styleVersion = is_readable($buildDir . '/style-shared.css')
            ? (string) filemtime($buildDir . '/style-shared.css')
            : ($sharedAsset['version'] ?? CLIQ_SL_VERSION);

        // Leaflet + markercluster are bundled locally (no remote CDN) but stay
        // filterable so a site can point at its own/CDN copy if it prefers.
        $vendor = plugins_url('resources/frontend/vendor', $this->pluginFile);
        $leafletJs = apply_filters('cliq_sl_leaflet_js', $vendor . '/leaflet/leaflet.js');
        $leafletCss = apply_filters('cliq_sl_leaflet_css', $vendor . '/leaflet/leaflet.css');
        $clusterJs = apply_filters('cliq_sl_leaflet_cluster_js', $vendor . '/leaflet.markercluster/leaflet.markercluster.js');
        $clusterCss = apply_filters('cliq_sl_leaflet_cluster_css', $vendor . '/leaflet.markercluster/MarkerCluster.css');
        $clusterCssDefault = apply_filters('cliq_sl_leaflet_cluster_css_default', $vendor . '/leaflet.markercluster/MarkerCluster.Default.css');

        wp_register_script(self::LEAFLET, $leafletJs, [], '1.9.4', true);
        wp_register_script(self::LEAFLET_CLUSTER, $clusterJs, [self::LEAFLET], '1.5.3', true);
        wp_register_style(self::LEAFLET, $leafletCss, [], '1.9.4');
        wp_register_style(self::LEAFLET_CLUSTER, $clusterCss, [self::LEAFLET], '1.5.3');
        wp_register_style(self::LEAFLET_CLUSTER . '-default', $clusterCssDefault, [self::LEAFLET_CLUSTER], '1.5.3');

        // Every frontend dropdown (search-form fields, distance/category/brand/sort
        // filters — see widgets.js GlideSelect) is powered by this, unconditionally
        // (unlike the map libraries above, which depend on the chosen provider) —
        // bundled locally like the others rather than pulled through webpack, so
        // it's filterable the same way. Loaded as window.Glide, not an ES import.
        $glideJs = apply_filters('cliq_sl_glide_js', $vendor . '/glide-dropdown/glide.js');
        $glideCss = apply_filters('cliq_sl_glide_css', $vendor . '/glide-dropdown/glide.css');
        wp_register_script(self::GLIDE, $glideJs, [], '0.3.0', true);
        wp_register_style(self::GLIDE, $glideCss, [], '0.3.0');

        $settings = SettingsService::all();
        $provider = (string) ($settings['map_provider'] ?? 'leaflet');
        $googleKey = (string) ($settings['google_maps_api_key'] ?? '');
        $googleExternallyLoaded = ! empty($settings['google_maps_api_loaded_externally']);
        $googleAvailable = $googleKey !== '' || $googleExternallyLoaded;
        $googleForMap = $provider === 'google' && $googleAvailable;
        // Places is also used by the Directions origin prompt. Load it whenever
        // a Google key is configured, independently of the chosen map provider;
        // search-form fields still decide individually whether to initialise
        // autocomplete on their own inputs.
        $googleForAutocomplete = $googleAvailable;

        // Google Maps JS API + marker clusterer, registered whenever either the
        // map provider or address-autocomplete needs it.
        if ($googleForMap || $googleForAutocomplete) {
            if ($googleExternallyLoaded) {
                // An alias handle keeps our local clusterer dependency chain valid
                // without printing a second Maps API script tag.
                wp_register_script(self::GOOGLE, false, [], CLIQ_SL_VERSION, true);
            } else {
                wp_register_script(
                    self::GOOGLE,
                    add_query_arg(
                        ['key' => $googleKey, 'libraries' => 'places,marker', 'loading' => 'async', 'v' => 'weekly'],
                        'https://maps.googleapis.com/maps/api/js'
                    ),
                    [],
                    CLIQ_SL_VERSION,
                    true
                );
            }
        }
        if ($googleForMap) {
            // Bundled locally (filterable); pairs with the Google Maps JS API.
            $clustererJs = apply_filters('cliq_sl_google_cluster_js', $vendor . '/googlemaps-markerclusterer/index.js');
            wp_register_script(self::GOOGLE_CLUSTER, $clustererJs, [self::GOOGLE], '2.5.3', true);
        }

        // The bundle itself doesn't depend on a specific map lib — that's enqueued
        // separately per the chosen provider in renderLocator(). Glide IS a
        // dependency here (unconditional, unlike the map libs): window.Glide must
        // exist before the bundle's widgets run, and the style dependency ensures
        // our own --glide-* variable overrides (style-index.css) print AFTER
        // glide.css so they win the cascade despite matching specificity.
        wp_register_script(self::SHARED_HANDLE, $buildUrl . '/shared.js', array_merge($sharedAsset['dependencies'] ?? [], [self::GLIDE]), $sharedAsset['version'] ?? CLIQ_SL_VERSION, true);
        wp_register_script(self::HANDLE, $buildUrl . '/locator.js', array_merge([self::SHARED_HANDLE], $locatorAsset['dependencies'] ?? []), $locatorAsset['version'] ?? CLIQ_SL_VERSION, true);
        $styleDependencies = [self::GLIDE];
        $themeFont = self::safeFontFamily((string) ($settings['theme_font_family'] ?? ''));
        $themeFontAlreadyLoaded = ! empty($settings['theme_font_loaded_by_theme']);
        if ($themeFont !== '' && ! self::isSystemFont($themeFont) && ! $themeFontAlreadyLoaded) {
            $family = str_replace('%20', '+', rawurlencode($themeFont));
            wp_register_style(
                self::THEME_FONT_HANDLE,
                'https://fonts.googleapis.com/css2?family=' . $family . ':wght@400;500;600;700&display=swap',
                [],
                CLIQ_SL_VERSION
            );
            $styleDependencies[] = self::THEME_FONT_HANDLE;
        }
        self::claimSharedStyleHandle($buildUrl . '/style-shared.css', $styleDependencies, $styleVersion);
        wp_register_style(self::SLIDER_VENDOR, $buildUrl . '/locator.css', [self::STYLE_HANDLE], is_readable($buildDir . '/locator.css') ? (string) filemtime($buildDir . '/locator.css') : CLIQ_SL_VERSION);
        $themeCss = self::themeCss($settings);
        if ($themeCss !== '') {
            wp_add_inline_style(self::STYLE_HANDLE, $themeCss);
        }
        /**
         * Register a feature's own frontend assets.
         *
         * @param string $buildUrl    URL of the compiled bundle directory
         * @param string $buildDir    its path, for cache-busting from mtime
         * @param string $pluginFile  the plugin's main file, for plugins_url()
         */
        do_action('cliq_sl_register_frontend_assets', $buildUrl, $buildDir, $this->pluginFile);

        // Make the bundle's __() strings translatable.
        if (function_exists('wp_set_script_translations')) {
            /**
             * Filter the script handles whose __() strings are translated.
             *
             * @param array<int, string> $handles
             */
            $translationHandles = (array) apply_filters(
                'cliq_sl_translated_script_handles',
                [self::SHARED_HANDLE, self::HANDLE]
            );
            foreach ($translationHandles as $handle) {
                wp_set_script_translations(
                    $handle,
                    'cliq-store-locator',
                    plugin_dir_path($this->pluginFile) . 'languages'
                );
            }
        }

        wp_localize_script(self::SHARED_HANDLE, 'CLIQ_SL', self::publicConfig([
            'restUrl' => esc_url_raw(rest_url('cliq-store-locator/v1')),
            'nonce' => wp_create_nonce('wp_rest'),
            // Current frontend language (Polylang/WPML page language) so the locator's
            // REST calls request translated content — a REST URL carries no language
            // prefix, so the multilingual plugin can't infer it without this.
            'lang' => \CliqThemes\StoreLocator\I18n\Multilingual::current(),
            'mapProvider' => $provider,
            'googleKey' => $googleKey,
            'unit' => (string) ($settings['distance_unit'] ?? 'mi'),
            'themeFontFamily' => $themeFont,
            'themeFontAlreadyLoaded' => $themeFontAlreadyLoaded,
            'defaultCenter' => [
                'lat' => (float) ($settings['map_default_lat'] ?? 0),
                'lng' => (float) ($settings['map_default_lng'] ?? 0),
            ],
            // Map behaviour derived purely from Settings (extracted + unit-tested in
            // self::mapConfig so a setting can't silently stop reaching the map).
            ...self::mapConfig($settings),
            // Responsive breakpoint: mobile (cssMobile / spanMobile) overrides apply
            // at/below this width. Driven by the Tablet preview-width setting.
            'mobileBreakpoint' => (int) ($settings['device_width_tablet'] ?? 768),
            'mobileOnlyBreakpoint' => (int) ($settings['device_width_mobile'] ?? 390),
            // Search behaviour (form structure/sorting/paging live on the widgets).
            'restrictCountry' => (string) ($settings['restrict_country'] ?? ''),
            // Token format/source hints so the renderer resolves fields identically
            // to the admin preview (logo→media, distance, hours, custom→meta.*).
            'tokens' => $this->tokens(),
            // Same, for 'category_tile' designs (Categories → stores drill-down) —
            // read via config.categoryTokens in CategoryBrowser (widgets.js).
            'categoryTokens' => $this->categoryTokens(),
            // Active saved icon markers are needed only when an individual Map
            // widget forces one marker across all of its stores.
            // Trusted inner SVG definitions. The public renderer deliberately
            // does not import lucide-react or the wp-admin icon registry.
            'icons' => DesignIcons::definitions($this->optimizedIconNames()),
            // Where each css control applies (control-key → property/selector) so the
            // frontend JS renderer composes styles exactly like the builder + PHP.
            'cssMap' => \CliqThemes\StoreLocator\Support\DesignRuntimeConfig::cssMap(),
            // Per-type look defaults, single-sourced so the frontend renderer matches
            // SSR/builder instead of hardcoding the same values in JS.
            'renderDefaults' => \CliqThemes\StoreLocator\Support\DesignRuntimeConfig::itemDefaults(),
        ], $settings, $this));
    }

    /**
     * The config object the frontend bundle reads from `window.CLIQ_SL`.
     *
     * Filtered, so a feature can add what its own widgets need.
     *
     * @param array<string, mixed> $config
     * @param array<string, mixed> $settings
     * @param self                 $module   the module assembling it
     *
     * @return array<string, mixed>
     */
    private static function publicConfig(array $config, array $settings, self $module): array
    {
        /**
         * Filter the public frontend config.
         *
         * @param array<string, mixed> $config
         * @param array<string, mixed> $settings the resolved plugin settings
         */
        $filtered = apply_filters('cliq_sl_frontend_config', $config, $settings, $module);

        return is_array($filtered) ? $filtered : $config;
    }

    /** @param array<string, mixed> $settings */
    private static function themeCss(array $settings): string
    {
        $tokens = [
            'theme_primary' => '--cliq-sl-primary',
            'theme_primary_strong' => '--cliq-sl-primary-strong',
            'theme_on_primary' => '--cliq-sl-on-primary',
            'theme_canvas' => '--cliq-sl-canvas',
            'theme_surface' => '--cliq-sl-surface',
            'theme_text' => '--cliq-sl-text',
            'theme_heading' => '--cliq-sl-heading',
            'theme_text_soft' => '--cliq-sl-text-soft',
            'theme_label' => '--cliq-sl-label',
            'theme_muted' => '--cliq-sl-muted',
            'theme_faint' => '--cliq-sl-faint',
            'theme_border' => '--cliq-sl-border',
            'theme_border_soft' => '--cliq-sl-border-soft',
            'theme_divider' => '--cliq-sl-divider',
            'theme_danger' => '--cliq-sl-danger',
            'theme_danger_bg' => '--cliq-sl-danger-bg',
        ];
        $declarations = [];
        foreach ($tokens as $settingKey => $cssVariable) {
            $color = sanitize_hex_color((string) ($settings[$settingKey] ?? ''));
            if (is_string($color) && $color !== '') {
                $declarations[] = $cssVariable . ':' . $color;
            }
        }
        $canvas = sanitize_hex_color((string) ($settings['theme_canvas'] ?? ''));
        if (is_string($canvas) && $canvas !== '') {
            $declarations[] = '--cliq-sl-on-canvas:' . self::contrastTextColor($canvas);
        }

        $font = self::safeFontFamily((string) ($settings['theme_font_family'] ?? ''));
        if ($font !== '') {
            $declarations[] = "--cliq-sl-font-family:'" . $font . "',sans-serif";
        }

        return $declarations === [] ? '' : ':root{' . implode(';', $declarations) . '}';
    }

    /**
     * Pick the higher-contrast neutral foreground for a theme canvas.
     *
     * Canvas text is deliberately derived instead of another user-facing
     * colour control. A light page canvas and dark cards are valid together,
     * and must not require the user to manually repair labels/counts whenever
     * the canvas changes.
     */
    private static function contrastTextColor(string $hex): string
    {
        $hex = ltrim($hex, '#');
        $channels = [
            hexdec(substr($hex, 0, 2)) / 255,
            hexdec(substr($hex, 2, 2)) / 255,
            hexdec(substr($hex, 4, 2)) / 255,
        ];
        $linear = array_map(
            static fn (float $channel): float => $channel <= 0.04045
                ? $channel / 12.92
                : (($channel + 0.055) / 1.055) ** 2.4,
            $channels
        );
        $luminance = 0.2126 * $linear[0] + 0.7152 * $linear[1] + 0.0722 * $linear[2];

        return $luminance > 0.42 ? '#0f172a' : '#f8fafc';
    }

    private static function safeFontFamily(string $font): string
    {
        return trim((string) preg_replace('/[^\p{L}\p{N} _-]/u', '', $font));
    }

    private static function isSystemFont(string $font): bool
    {
        return in_array(strtolower($font), [
            'arial', 'helvetica', 'georgia', 'times new roman', 'courier new',
            'verdana', 'tahoma', 'trebuchet ms', 'system ui', 'system-ui',
        ], true);
    }

    /**
     * Core + custom-field tokens for the renderer (JS live path and PHP SSR path
     * alike). publishCustomFieldFormats() is a cheap, request-scoped call — unlike
     * StoreProviderDefinition::register(), which only runs behind
     * Plugin::needsAdminLayer(), this must run here so a public page load resolves
     * a 'toggle'/'wysiwyg'/'hours' custom field to the same format the builder does,
     * instead of silently falling back to 'text'.
     *
     * @return array<int, array<string, string>>
     */
    private function tokens(): array
    {
        StoreProviderDefinition::publishCustomFieldFormats();

        return array_merge(DesignTokens::core(), DesignTokens::customFromMeta());
    }

    /**
     * Tokens for 'category_tile' designs (Categories → stores drill-down) —
     * categories have no custom fields, so this is just the static core list.
     *
     * @return array<int, array<string, string>>
     */
    private function categoryTokens(): array
    {
        return DesignTokens::categoryCore();
    }


    /** @return array<string,mixed>|null */
    public function optimizedAssets(): ?array
    {
        return SettingsService::get('optimize_frontend_assets', false)
            ? FrontendAssetRegistry::get()
            : null;
    }

    /** @return string[]|null */
    private function optimizedIconNames(): ?array
    {
        $assets = $this->optimizedAssets();

        return $assets === null ? null : ($assets['icons'] ?? []);
    }

    /**
     * Map behaviour the frontend reads from the global config. Kept as a pure
     * settings→config mapping (no WordPress calls) so it is unit-testable: every
     * key here must be consumed by the map providers, and a regression that drops
     * one (e.g. the old "clicked zoom / search zoom never applied" bug) is caught
     * by FrontendConfigTest rather than only in the browser.
     *
     * @param array<string, mixed> $settings
     *
     * @return array<string, mixed>
     */
    public static function mapConfig(array $settings): array
    {
        return [
            'defaultZoom' => (int) ($settings['map_default_zoom'] ?? 5),
            'searchZoom' => (int) ($settings['search_zoom'] ?? 12),
            'clickedZoom' => (int) ($settings['clicked_zoom'] ?? 13),
            'mapType' => (string) ($settings['map_type'] ?? 'roadmap'),
            'clustering' => (bool) ($settings['enable_clustering'] ?? true),
            'clusterStyle' => (string) ($settings['cluster_style'] ?? 'soft'),
            'clusterColor' => (string) ($settings['cluster_color'] ?? '#3858e9'),
            'markerAnimation' => (string) ($settings['marker_animation'] ?? 'none'),
            'markerSize' => (int) ($settings['marker_size'] ?? 40),
            // Resolve only the selected Google style into public configuration.
            // The full preset gallery remains an admin-only Builder asset.
            'resolvedMapStyles' => MapStyleResolver::resolve(
                (string) ($settings['map_style'] ?? 'standard'),
                $settings['map_style_custom'] ?? ''
            ),
            'radiusCircle' => (bool) ($settings['radius_circle'] ?? false),
            'mouseScroll' => (bool) ($settings['mouse_scroll'] ?? true),
        ];
    }

    public function registerShortcode(): void
    {
        add_shortcode('cliq_store_locator', [$this, 'renderShortcode']);
    }

    /**
     * @param array<string, mixed>|string $atts
     */
    public function renderShortcode($atts): string
    {
        // These are shortcode attribute names, not database meta queries.
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        $atts = shortcode_atts(
            ['id' => 0, 'country' => '', 'category' => '', 'brand' => '', 'q' => '', 'meta_key' => '', 'meta_value' => '', 'per_page' => 0],
            is_array($atts) ? $atts : [],
            'cliq_store_locator'
        );

        $output = $this->renderLocator((int) $atts['id'], [
            'country' => (string) $atts['country'],
            'category' => (string) $atts['category'],
            'brand' => (string) $atts['brand'],
            'q' => (string) $atts['q'],
            'meta_key' => sanitize_key((string) $atts['meta_key']),
            'meta_value' => (string) $atts['meta_value'],
            'per_page' => (int) $atts['per_page'],
        ]);
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value

        return $output;
    }

    public function registerBlock(): void
    {
        if (! function_exists('register_block_type')) {
            return;
        }

        $editorUrl = plugins_url('blocks/store-locator/editor.js', $this->pluginFile);
        wp_register_script(
            self::EDITOR_HANDLE,
            $editorUrl,
            ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-api-fetch', 'wp-i18n'],
            CLIQ_SL_VERSION,
            true
        );

        register_block_type(plugin_dir_path($this->pluginFile) . 'blocks/store-locator', [
            'render_callback' => [$this, 'renderBlock'],
        ]);
    }

    /**
     * @param array<string, mixed> $attributes
     */
    public function renderBlock(array $attributes): string
    {
        // These are block attribute names, not database meta queries.
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        $output = $this->renderLocator((int) ($attributes['templateId'] ?? 0), [
            'country' => (string) ($attributes['country'] ?? ''),
            'category' => (string) ($attributes['category'] ?? ''),
            'brand' => (string) ($attributes['brand'] ?? ''),
            'q' => (string) ($attributes['q'] ?? ''),
            'meta_key' => sanitize_key((string) ($attributes['metaKey'] ?? '')),
            'meta_value' => (string) ($attributes['metaValue'] ?? ''),
            'per_page' => (int) ($attributes['perPage'] ?? 0),
        ]);
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value

        if ($output === '') {
            return '';
        }

        // block.json declares wide and full alignment support, but the markup is
        // built here rather than by core, so the alignment the editor recorded
        // has to be applied to a wrapper — otherwise choosing Wide or Full in the
        // editor changes nothing on the page.
        $align = (string) ($attributes['align'] ?? '');
        if (! in_array($align, ['wide', 'full'], true)) {
            return $output;
        }

        // $output is renderLocator()'s return value, already reduced to the
        // SafeHtml allowlist; only the wrapper is added here, and its one
        // attribute is escaped.
        return sprintf(
            '<div class="%s">%s</div>',
            esc_attr('wp-block-cliq-store-locator align' . $align),
            $output // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Filtered by SafeHtml::filter() in renderLocator().
        );
    }

    // ---- Standalone lead / registration forms --------------------------------






    /**
     * @param array<string, mixed> $defaults Optional default filters (country /
     *        category / brand / q) applied to the initial browse, so a placement
     *        can show e.g. one country's stores in a results-only template, plus
     *        an optional per_page (int) page-size override for this placement.
     */
    private function renderLocator(int $templateId, array $defaults = []): string
    {
        // Zero means the placement named no layout — NOT "invalid". A layout that
        // is assembled rather than stored has a negative id on purpose, to stay
        // clear of database-backed ones, so it must survive this check.
        if ($templateId === 0) {
            $templateId = LocatorLayouts::source()->defaultId();
        }
        if ($templateId === 0) {
            // The site has no layout to render yet. Say so to someone who can
            // fix it, and stay silent for everybody else.
            return current_user_can('edit_posts')
                ? '<div class="cliq-sl-notice">' . esc_html__('Select a store locator template.', 'cliq-store-locator') . '</div>'
                : '';
        }

        wp_enqueue_script(self::HANDLE);
        wp_enqueue_style(self::SLIDER_VENDOR);

        // Enqueue provider assets only when this locator contains a Map widget.
        // A search-only locator can still request Google Places without paying
        // for Leaflet or marker clustering.
        $settings = SettingsService::all();
        $capabilities = $this->locatorCapabilities($templateId);

        // A gallery may be rendered by the app rather than by PHP (an in-place
        // detail panel, category tiles), so enqueue its slider/lightbox assets
        // whenever any design this layout references contains one.
        if (! empty($capabilities['gallery'])) {
            do_action('cliq_sl_enqueue_render_assets', 'gallery');
        }

        $hasMap = $capabilities['map'];
        $hasSearch = $capabilities['search'];
        $provider = (string) ($settings['map_provider'] ?? 'leaflet');
        $googleKey = (string) ($settings['google_maps_api_key'] ?? '');
        $googleExternallyLoaded = ! empty($settings['google_maps_api_loaded_externally']);
        $googleAvailable = $googleKey !== '' || $googleExternallyLoaded;
        $googleReady = $hasMap && $provider === 'google' && $googleAvailable;
        // Independent of the map provider: Places powers both opted-in Search
        // Form fields and the Directions origin prompt.
        $autocompleteReady = $googleAvailable && ($hasSearch || $hasMap);

        if ($googleReady) {
            wp_enqueue_script(self::GOOGLE);
            if (! empty($settings['enable_clustering'])) {
                wp_enqueue_script(self::GOOGLE_CLUSTER);
            }
        } elseif ($hasMap) {
            wp_enqueue_script(self::LEAFLET);
            wp_enqueue_script(self::LEAFLET_CLUSTER);
            wp_enqueue_style(self::LEAFLET);
            wp_enqueue_style(self::LEAFLET_CLUSTER);
            wp_enqueue_style(self::LEAFLET_CLUSTER . '-default');
            if ($autocompleteReady) {
                wp_enqueue_script(self::GOOGLE);
            }
        } elseif ($autocompleteReady) {
            wp_enqueue_script(self::GOOGLE);
        }

        // Server-side (SSR) mode: render the COMPLETE locator (grid, search form,
        // filters, first-page store cards, empty map wrapper) plus an inlined
        // bootstrap JSON inside the mount root. The app boots from the bootstrap
        // at domReady — no REST fetch, no loading frame — and only initialises
        // the map into its wrapper. Crawlers and no-JS visitors get the full page.
        $ssr = '';
        // A URL-submitted search is parsed by the browser before its first REST
        // request. Do not inline an unfiltered SSR bootstrap for that request:
        // LocatorApp intentionally skips its first fetch when bootstrapped.
        // Public, read-only locator state; no mutation occurs and a nonce would
        // make shareable/search-engine-visible locator URLs impossible.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $hasUrlSearch = isset($_GET['sl-action'])
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            && sanitize_key((string) wp_unslash($_GET['sl-action'])) === 'search';
        if (! $hasUrlSearch && (string) SettingsService::get('rendering_mode', 'lazy-load') === 'server-side') {
            $ssr = $this->renderLocatorSsr($templateId, $defaults);
        }

        // Default filters → data-* attributes the frontend seeds into the initial
        // query (only non-empty ones emitted).
        $dataAttrs = sprintf(' data-template-id="%d"', $templateId);
        foreach (['country', 'category', 'brand', 'q'] as $key) {
            $value = isset($defaults[$key]) ? trim((string) $defaults[$key]) : '';
            if ($value !== '') {
                $dataAttrs .= sprintf(' data-%s="%s"', $key, esc_attr($value));
            }
        }
        // Placement attribute names, not database meta queries.
        // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        foreach (['meta_key' => 'meta-key', 'meta_value' => 'meta-value'] as $key => $attribute) {
            $value = isset($defaults[$key]) ? trim((string) $defaults[$key]) : '';
            if ($value !== '') {
                $dataAttrs .= sprintf(' data-%s="%s"', $attribute, esc_attr($value));
            }
        }
        // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
        // Placement page-size override (shortcode per_page / block perPage) —
        // beats the results widget's own pageSize when set.
        if ((int) ($defaults['per_page'] ?? 0) > 0) {
            $dataAttrs .= sprintf(' data-per-page="%d"', (int) $defaults['per_page']);
        }

        // The bootstrap is a JSON data island encoded with JSON_HEX_TAG, so it
        // is separated out and re-attached after filtering: kses would strip the
        // element, and it carries no markup of its own to check.
        $bootstrap = '';
        if ($ssr !== '' && preg_match('#<script type="application/json"[^>]*>.*?</script>#s', $ssr, $island) === 1) {
            $bootstrap = $island[0];
            $ssr = str_replace($bootstrap, '', $ssr);
        }

        return SafeHtml::filter(
            sprintf('<div class="cliq-sl-root"%s>%s</div>', $dataAttrs, $ssr)
        ) . $bootstrap;
    }

    /** @return array{map:bool,search:bool,gallery:bool} */
    private function locatorCapabilities(int $templateId): array
    {
        $found = ['map' => false, 'search' => false, 'gallery' => false];
        // The layout source already knows how to fetch a layout by id, whether it
        // is assembled or stored; this only needs its grid.
        $model = LocatorLayouts::source()->find($templateId)['model'] ?? null;
        if (! is_array($model)) {
            return $found;
        }

        $walk = static function (array $node) use (&$walk, &$found): void {
            foreach ($node as $key => $value) {
                if ($key === 'widget' && is_scalar($value)) {
                    $widget = sanitize_key((string) $value);
                    if ($widget === 'map') {
                        $found['map'] = true;
                    } elseif ($widget === 'search') {
                        $found['search'] = true;
                    }
                }
                if (is_array($value)) {
                    $walk($value);
                }
            }
        };
        $walk($model);

        $found['gallery'] = self::referencesGallery($model);

        return $found;
    }

    /**
     * Whether anything this layout can put on screen contains a gallery.
     *
     * Some designs are rendered by the app rather than by PHP — the in-place
     * detail panel, category tiles — from the bootstrap, long after this request
     * has finished. Those never pass through DesignRenderer, so used('gallery')
     * cannot see them, and a gallery inside one would render as markup with no
     * slider or lightbox behind it.
     *
     * The designs a layout references are known here, so check them up front and
     * let the caller enqueue the gallery assets before the page is sent.
     *
     * @param array<string, mixed> $model the layout model
     */
    private static function referencesGallery(array $model): bool
    {
        $hasGalleryToken = static function (array $design): bool {
            $found = false;
            $walk = static function ($node) use (&$walk, &$found): void {
                if ($found || ! is_array($node)) {
                    return;
                }
                if (($node['token'] ?? '') === 'gallery') {
                    $found = true;

                    return;
                }
                foreach ($node as $child) {
                    if (is_array($child)) {
                        $walk($child);
                    }
                }
            };
            $walk($design);

            return $found;
        };

        // The layout itself first — a gallery can sit directly on it.
        if ($hasGalleryToken($model)) {
            return true;
        }

        // Then every design it points at, whoever ends up rendering them.
        $ids = [];
        $collect = static function ($node) use (&$collect, &$ids): void {
            if (! is_array($node)) {
                return;
            }
            if (($node['type'] ?? '') === 'widget' && is_array($node['widgetSettings'] ?? null)) {
                foreach (DesignRuntimeConfig::DESIGN_REFERENCE_KEYS as $key) {
                    $id = (int) ($node['widgetSettings'][$key] ?? 0);
                    if ($id > 0) {
                        $ids[$id] = true;
                    }
                }
            }
            foreach ($node as $child) {
                if (is_array($child)) {
                    $collect($child);
                }
            }
        };
        $collect($model);

        // The lookup itself lives on PublicController, which owns every read of
        // the design table. This class only decides which ids to ask about.
        if ($ids !== [] && (new PublicController())->designsContainGallery(array_keys($ids))) {
            return true;
        }

        return false;
    }

    /**
     * Register the shared stylesheet, taking the handle back if a design render
     * already claimed it.
     *
     * DesignRenderer::enqueueCss() has to attach its generated CSS to a handle,
     * and registers this one with no src when it does not already exist. That
     * happens whenever a design renders before wp_enqueue_scripts — which
     * server-side rendering mode does. wp_register_style() silently ignores a
     * handle that is already registered, so the placeholder would shadow
     * style-shared.css for the rest of the request and every locator on the page
     * would render with no stylesheet at all. Swap the placeholder out, keeping
     * the inline CSS and enqueued state it had already collected.
     *
     * @param array<int, string> $dependencies
     */
    private static function claimSharedStyleHandle(string $src, array $dependencies, string $version): void
    {
        $existing = wp_styles()->registered[self::STYLE_HANDLE] ?? null;

        if ($existing === null || ! empty($existing->src)) {
            wp_register_style(self::STYLE_HANDLE, $src, $dependencies, $version);

            return;
        }

        $inline = isset($existing->extra['after']) && is_array($existing->extra['after'])
            ? $existing->extra['after']
            : [];
        $wasEnqueued = wp_style_is(self::STYLE_HANDLE, 'enqueued');

        wp_deregister_style(self::STYLE_HANDLE);
        wp_register_style(self::STYLE_HANDLE, $src, $dependencies, $version);

        foreach ($inline as $css) {
            if (is_string($css) && $css !== '') {
                wp_add_inline_style(self::STYLE_HANDLE, $css);
            }
        }
        if ($wasEnqueued) {
            wp_enqueue_style(self::STYLE_HANDLE);
        }
    }

    /**
     * True SSR for server-side mode: the full locator markup + bootstrap JSON
     * (see LocatorSsrRenderer). Returns '' when the template can't be resolved
     * or the render fails — the app then boots exactly like lazy-load mode.
     *
     * @param array<string, mixed> $defaults country|category|brand|q placement filters
     */
    private function renderLocatorSsr(int $templateId, array $defaults = []): string
    {
        $api = new PublicController();
        $payload = $api->templatePayload($templateId);
        if ($payload === null) {
            return '';
        }

        $filters = [];
        foreach (['country', 'category', 'brand', 'q', 'meta_key', 'meta_value'] as $key) {
            $value = isset($defaults[$key]) ? trim((string) $defaults[$key]) : '';
            if ($value !== '') {
                $filters[$key] = $value;
            }
        }

        $model = is_array($payload['model'] ?? null) ? $payload['model'] : [];
        // Page size / unit parity with the app: the results widget's pageSize
        // (JS default 50) and the distance widget's unit drive the initial browse,
        // NOT the results_per_page setting — otherwise the SSR list and the
        // hydrated list would differ. browseResponse() clamps like /search does.
        $resultsWs = LocatorSsrRenderer::firstWidgetSettings($model, 'results');
        $distWs = LocatorSsrRenderer::firstWidgetSettings($model, 'distance');
        // Visible statuses (Store List widget setting, default Active-only) —
        // first paint must match the hydrated app's filtering exactly, same
        // reasoning as pageSize below.
        $statuses = is_array($resultsWs['statuses'] ?? null) && $resultsWs['statuses'] !== []
            ? $resultsWs['statuses']
            : ['active'];
        $filters['statuses'] = implode(',', array_map('sanitize_key', $statuses));
        $perPage = is_numeric($resultsWs['pageSize'] ?? null) ? (int) $resultsWs['pageSize'] : 50;
        // Placement override (shortcode per_page / block perPage) beats the
        // widget's pageSize — same precedence the app uses (data-per-page →
        // seedQuery). NOT a browse filter, so it never enters $filters.
        if ((int) ($defaults['per_page'] ?? 0) > 0) {
            $perPage = (int) $defaults['per_page'];
        }
        $unit = (string) ($distWs['unit'] ?? SettingsService::get('distance_unit', 'mi'));
        $search = $api->browseResponse($perPage, $filters, $unit);

        $tokens = $this->tokens();
        $categoryRenderer = new DesignRenderer($this->categoryTokens());

        try {
            return (new LocatorSsrRenderer($api, new DesignRenderer($tokens), SettingsService::all(), $categoryRenderer))
                ->render($templateId, $payload, $search, $filters);
        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * [cliq_store_detail] — SERVER-renders the store for the current detail URL
     * using the configured Store Card (list_item) design, so the page is real
     * HTML (SEO/meta, no client fetch) — like a regular single post.
     *
     * @param array<string, mixed>|string $atts
     */
    public function renderStoreDetail($atts): string
    {
        if (! SettingsService::get('store_detail_pages', true)) {
            return '';
        }

        $slug = sanitize_title((string) get_query_var('cliq_store'));
        if ($slug === '') {
            return current_user_can('edit_posts')
                ? '<div class="cliq-sl-notice">' . esc_html__('The store detail renders here when this page is opened via a store URL.', 'cliq-store-locator') . '</div>'
                : '';
        }

        $store = $this->currentDetailStore();
        $model = LocatorLayouts::source()->detailModel();
        if ($store === null || $model === null) {
            return current_user_can('edit_posts')
                ? '<div class="cliq-sl-notice">' . esc_html__('No store was found for this address.', 'cliq-store-locator') . '</div>'
                : '';
        }

        $tokens = $this->tokens();
        $renderer = new DesignRenderer($tokens);
        $html = $renderer->render($model, $store);

        wp_enqueue_style(self::STYLE_HANDLE);

        if ($renderer->used('gallery')) {
            /**
             * A rendered design used an optional renderer, so whatever owns it
             * should enqueue the browser code it needs.
             *
             * @param string $feature the renderer that produced output
             */
            do_action('cliq_sl_enqueue_render_assets', 'gallery');
        }

        return SafeHtml::filter('<div class="cliq-sl-store-detail">' . $html . '</div>');
    }

    /**
     * Automatically render the selected store-detail page.
     *
     * A manually inserted shortcode remains supported and takes precedence, but
     * assigning a base page is sufficient—the page itself may be empty.
     */
    public function injectStoreDetailContent(string $content): string
    {
        $pageId = (int) SettingsService::get('store_detail_page', 0);
        $slug = sanitize_title((string) get_query_var('cliq_store'));
        if (
            $pageId <= 0
            || $slug === ''
            || ! SettingsService::get('store_detail_pages', true)
            || ! is_singular('page')
            || get_queried_object_id() !== $pageId
            || ! in_the_loop()
            || ! is_main_query()
        ) {
            return $content;
        }

        if (has_shortcode($content, 'cliq_store_detail')) {
            return $content;
        }

        // $content is whatever the_content filters have already produced; the
        // part this plugin adds is filtered by renderStoreDetail().
        return $content . $this->renderStoreDetail([]);
    }


    /**
     * Resolve + cache the current detail page's store payload (by slug).
     *
     * @return array<string, mixed>|null
     */
    private function currentDetailStore(): ?array
    {
        if ($this->detailStoreResolved) {
            return $this->detailStore;
        }
        $this->detailStoreResolved = true;

        $slug = sanitize_title((string) get_query_var('cliq_store'));
        if ($slug === '' || ! SettingsService::get('store_detail_pages', true)) {
            return null;
        }

        $this->detailStore = (new PublicController())->payloadForSlug($slug);

        return $this->detailStore;
    }


    /** Add the store name to the <title> on its detail page. */
    public function storeDetailTitle(array $parts): array
    {
        $store = $this->currentDetailStore();
        if (is_array($store) && ! empty($store['name'])) {
            $parts['title'] = (string) $store['name'];
        }

        return $parts;
    }

    /** On a store detail URL, drop WP's base-page canonical so ours wins. */
    public function prepareStoreDetailHead(): void
    {
        if (sanitize_title((string) get_query_var('cliq_store')) === '' || ! SettingsService::get('store_detail_pages', true)) {
            return;
        }
        remove_action('wp_head', 'rel_canonical');
    }

    /** Emit description/Open Graph/canonical meta + JSON-LD on the detail page. */
    public function renderStoreDetailSeo(): void
    {
        $store = $this->currentDetailStore();
        if ($store === null) {
            return;
        }

        // When an SEO plugin (Yoast / Rank Math / SEOPress) is active it owns the
        // title/OG/canonical tags via its own filters (see Integrations\*);
        // emitting our own here would duplicate them. The JSON-LD is additive.
        if ($this->integrations !== null && $this->integrations->anySeoActive()) {
            if (! empty($store['id'])) {
                echo (new SeoSchemaService())->scriptTag((int) $store['id']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
            }
            return;
        }

        $name = (string) ($store['name'] ?? '');
        $desc = wp_strip_all_tags((string) ($store['description'] ?? ''));
        $desc = $desc !== '' ? wp_trim_words($desc, 30) : '';
        $image = '';
        if (is_array($store['hero_media'] ?? null)) {
            $image = (string) ($store['hero_media']['url'] ?? '');
        } elseif (is_string($store['hero_media'] ?? null)) {
            $image = (string) $store['hero_media'];
        }
        $url = (string) ($store['detail_url'] ?? self::storePermalink((string) ($store['slug'] ?? '')));

        $tags = [];
        if ($desc !== '') {
            $tags[] = '<meta name="description" content="' . esc_attr($desc) . '" />';
            $tags[] = '<meta property="og:description" content="' . esc_attr($desc) . '" />';
        }
        $tags[] = '<meta property="og:title" content="' . esc_attr($name) . '" />';
        $tags[] = '<meta property="og:type" content="business.business" />';
        if ($url !== '') {
            $tags[] = '<meta property="og:url" content="' . esc_url($url) . '" />';
            $tags[] = '<link rel="canonical" href="' . esc_url($url) . '" />';
        }
        if ($image !== '') {
            $tags[] = '<meta property="og:image" content="' . esc_url($image) . '" />';
        }
        echo "\n" . implode("\n", $tags) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped

        if (! empty($store['id'])) {
            echo (new SeoSchemaService())->scriptTag((int) $store['id']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        }
    }
}
