<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\REST;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\Layouts\BundledLocatorPreset;
use CliqThemes\StoreLocator\Layouts\BundledLocatorBuilder;
// The controller's few direct calls target plugin-owned lookup/event tables.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

use CliqThemes\StoreLocator\I18n\ContentTranslator;
use CliqThemes\StoreLocator\I18n\Multilingual;
use CliqThemes\StoreLocator\I18n\StringTranslator;
use CliqThemes\StoreLocator\Layouts\LocatorLayouts;
use CliqThemes\StoreLocator\Models\Category;
use CliqThemes\StoreLocator\Models\Location;
use CliqThemes\StoreLocator\Models\Store;
use CliqThemes\StoreLocator\Repository\CustomFieldsRepository;
use CliqThemes\StoreLocator\Repository\StoreReadRepository;
use CliqThemes\StoreLocator\Support\CountryOptions;
use CliqThemes\StoreLocator\Support\FrontendAssetRegistry;
use CliqThemes\StoreLocator\Support\MapStyleResolver;
use CliqThemes\StoreLocator\Support\RequestThrottle;
use CliqThemes\StoreLocator\Schema\SchemaInstaller;
use CliqThemes\StoreLocator\Support\StorePayloadFormatter;
use CliqThemes\StoreLocator\Support\StoreDetailUrl;
use WP_REST_Request;
use WP_REST_Response;
use CliqThemes\StoreLocator\Support\StoredValue;

/**
 * Public, unauthenticated REST surface for the storefront locator.
 *
 * Namespace: cliq-store-locator/v1
 *   GET  /search       Spatial radius search (lat/lng/radius) with filters.
 *   GET  /stores/{id}  Single store with primary location, hours, categories.
 *   GET  /categories   Active categories for filter UIs.
 *
 * Read endpoints intentionally expose only active public records.
 */
final class PublicController
{
    use RespondsToRest;

    public const NAMESPACE = 'cliq-store-locator/v1';

    private const MAX_PER_PAGE = 100;

    /**
     * Serializes Store models (+ relations) into the REST payload shape. One
     * instance per request so a search can prefetch() relations once and then
     * format() each store off the warm caches (see {@see search()}).
     */
    private ?StorePayloadFormatter $formatter = null;

    private function formatter(): StorePayloadFormatter
    {
        return $this->formatter ??= new StorePayloadFormatter();
    }

    /** Read-side store queries (filter id-sets + category counts). */
    private ?StoreReadRepository $reads = null;

    private function reads(): StoreReadRepository
    {
        return $this->reads ??= new StoreReadRepository();
    }

    public function register(): void
    {
        add_action('rest_api_init', [$this, 'registerRoutes']);
    }

    public function registerRoutes(): void
    {
        register_rest_route(self::NAMESPACE, '/search', [
            'methods' => 'GET',
            'callback' => [$this, 'search'],
            'permission_callback' => '__return_true',
            'args' => $this->searchArgs(),
        ]);

        register_rest_route(self::NAMESPACE, '/stores/slug/(?P<slug>[A-Za-z0-9\-_]+)', [
            'methods' => 'GET',
            'callback' => [$this, 'storeBySlug'],
            'permission_callback' => '__return_true',
        ]);

        register_rest_route(self::NAMESPACE, '/stores/(?P<id>\d+)', [
            'methods' => 'GET',
            'callback' => [$this, 'store'],
            'permission_callback' => '__return_true',
        ]);

        register_rest_route(self::NAMESPACE, '/preview', [
            'methods' => 'GET',
            'callback' => [$this, 'previewStores'],
            'permission_callback' => [$this, 'adminPermission'],
        ]);

        register_rest_route(self::NAMESPACE, '/categories', [
            'methods' => 'GET',
            'callback' => [$this, 'categories'],
            'permission_callback' => '__return_true',
        ]);

        register_rest_route(self::NAMESPACE, '/field-options', [
            'methods' => 'GET',
            'callback' => [$this, 'fieldOptions'],
            'permission_callback' => '__return_true',
        ]);



        // The layout LIST is an editor picker. The single-layout route below stays
        // public: that is the payload a placed locator renders from.
        register_rest_route(self::NAMESPACE, '/templates', [
            'methods' => 'GET',
            'callback' => [$this, 'templates'],
            'permission_callback' => [$this, 'editorPermission'],
        ]);

        register_rest_route(self::NAMESPACE, '/templates/(?P<id>-?\d+)', [
            'methods' => 'GET',
            'callback' => [$this, 'template'],
            'permission_callback' => '__return_true',
        ]);
    }

    /**
     * Builder preview data can include draft and otherwise non-public records.
     */
    public function adminPermission(): bool
    {
        return current_user_can('manage_store_locator') || current_user_can('manage_options');
    }

    /**
     * For the pickers the block editor and the page-builder widgets populate.
     *
     * These list saved layouts and forms by name. A placed locator receives its
     * layout inline, so the only caller is someone editing a post.
     */
    public function editorPermission(): bool
    {
        return current_user_can('edit_posts') || $this->adminPermission();
    }

    /**
     * Argument schema for /search: validates + sanitises scalar query params at
     * the REST boundary (self-documenting via OPTIONS). The free-form meta/metaop
     * maps are validated inside the handler.
     *
     * @return array<string, array<string, mixed>>
     */
    private function searchArgs(): array
    {
        return [
            'lat' => ['type' => 'number', 'required' => false, 'minimum' => -90, 'maximum' => 90],
            'lng' => ['type' => 'number', 'required' => false, 'minimum' => -180, 'maximum' => 180],
            // Capped: the radius decides how much of the location table a
            // single anonymous request scans. Earth's circumference is the
            // practical ceiling, so anything beyond this is a load request
            // rather than a search.
            'radius' => ['type' => 'number', 'required' => false, 'minimum' => 0.1, 'maximum' => self::MAX_RADIUS],
            'unit' => ['type' => 'string', 'enum' => ['km', 'mi'], 'default' => 'km'],
            'page' => ['type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint'],
            // Can't use bare intval: the REST layer calls sanitize_callback with
            // 3 arguments while intval() accepts at most 2; wrap it instead.
            'per_page' => ['type' => 'integer', 'default' => 20, 'sanitize_callback' => static fn ($value): int => (int) $value],
            'city' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'country' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            // A single id ("5") or the frontend's optional multi-select filter
            // ("5,12,30") — parsed by parseIdList(), not a REST-layer int cast.
            'category' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'brand' => ['type' => 'integer', 'sanitize_callback' => 'absint'],
            'q' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'open' => ['type' => 'boolean', 'default' => false],
            'sort' => ['type' => 'string', 'enum' => ['distance', 'name'], 'default' => 'distance'],
            'lang' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            // Comma list of statuses to include (default when absent: Active
            // only — see parseStatusList()); set from the Results widget's
            // "Visible statuses" setting.
            'status' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            // Immutable placement constraints supplied by a shortcode, block or
            // page-builder widget. They are deliberately separate from the
            // visitor-controlled search-form parameters above.
            'fixed_city' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'fixed_country' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'fixed_category' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'fixed_brand' => ['type' => 'integer', 'sanitize_callback' => 'absint'],
            'fixed_q' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
            'fixed_meta_key' => ['type' => 'string', 'sanitize_callback' => 'sanitize_key'],
            'fixed_meta_value' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'],
        ];
    }


    /**
     * Spatial radius search. When lat/lng are supplied results are distance
     * sorted; otherwise stores are returned by curated ordering.
     */
    public function search(WP_REST_Request $request): WP_REST_Response
    {
        if (($throttled = RequestThrottle::exceeded('search', 120, MINUTE_IN_SECONDS)) !== null) {
            return $throttled;
        }

        $this->applyRequestLanguage($request);
        $perPage = $this->clampPerPage((int) $request->get_param('per_page'));
        $page = max(1, (int) $request->get_param('page'));
        $unit = strtolower((string) $request->get_param('unit')) === 'mi' ? 'mi' : 'km';
        $sort = (string) $request->get_param('sort') === 'name' ? 'name' : 'distance';

        $lat = $request->get_param('lat');
        $lng = $request->get_param('lng');
        $hasGeo = $this->validCoordinates($lat, $lng);
        $radius = (float) $request->get_param('radius');
        $radius = $radius > 0 ? $radius : 50.0;

        $city = $this->sanitizeText($request->get_param('city'));
        $country = $this->sanitizeText($request->get_param('country'));
        $categoryIds = $this->withCategoryDescendants($this->parseIdList($request->get_param('category')));
        $brandId = (int) $request->get_param('brand');
        $term = $this->sanitizeText($request->get_param('q'));

        $fixedCity = $this->sanitizeText($request->get_param('fixed_city'));
        $fixedCountry = $this->sanitizeText($request->get_param('fixed_country'));
        $fixedCategoryIds = $this->withCategoryDescendants($this->parseIdList($request->get_param('fixed_category')));
        $fixedBrandId = (int) $request->get_param('fixed_brand');
        $fixedTerm = $this->sanitizeText($request->get_param('fixed_q'));
        $fixedMetaKey = sanitize_key((string) $request->get_param('fixed_meta_key'));
        $fixedMetaValue = $this->sanitizeText($request->get_param('fixed_meta_value'));

        // A store has one primary city/country. If a visitor filter requests a
        // different value from the placement constraint, the valid
        // intersection is empty; otherwise use the hard value for SQL.
        $locationConstraint = null;
        if (
            ($fixedCity !== '' && $city !== '' && strcasecmp($fixedCity, $city) !== 0)
            || ($fixedCountry !== '' && $country !== '' && strcasecmp($fixedCountry, $country) !== 0)
        ) {
            $locationConstraint = [];
        }
        if ($fixedCity !== '') {
            $city = $fixedCity;
        }
        if ($fixedCountry !== '') {
            $country = $fixedCountry;
        }

        // Optional filter sets (store id => true) intersected with the spatial set.
        $categoryFilter = $categoryIds !== [] ? $this->reads()->storeIdsForPivot('cliq_sl_store_category', 'category_id', $categoryIds) : null;
        $brandFilter = $brandId > 0 ? $this->reads()->storeIdsForPivot('cliq_sl_brand_store', 'brand_id', $brandId) : null;
        $termFilter = $term !== '' ? $this->reads()->storeIdsForTerm($term) : null;
        $fixedCategoryFilter = $fixedCategoryIds !== [] ? $this->reads()->storeIdsForPivot('cliq_sl_store_category', 'category_id', $fixedCategoryIds) : null;
        $fixedBrandFilter = $fixedBrandId > 0 ? $this->reads()->storeIdsForPivot('cliq_sl_brand_store', 'brand_id', $fixedBrandId) : null;
        $fixedTermFilter = $fixedTerm !== '' ? $this->reads()->storeIdsForTerm($fixedTerm) : null;
        $fixedMetaFilter = $fixedMetaKey !== '' && $fixedMetaValue !== ''
            ? $this->reads()->storeIdsForMeta($fixedMetaKey, $fixedMetaValue, '=')
            : null;
        // "Open now": restrict to stores currently open (site timezone). null when
        // the toggle is off; an array (possibly empty) when on — empty = none open.
        $openFilter = $request->get_param('open') ? $this->reads()->storeIdsOpenNow() : null;
        // Language + status are UNCONDITIONAL — every search is scoped to the
        // current language's original rows (never a mix, and never a
        // translation-sibling row surfacing as its own result — see
        // storeIdsForLanguage()) and to the visible statuses (default: Active
        // only, overridable via the Results widget's "Visible statuses" setting).
        // Carried as a join on every location query below — see
        // scopeToVisibleStores() — instead of being resolved into id sets here.
        $visibleStatuses = $this->parseStatusList($request->get_param('status'));

        // Custom-field filters: meta[key]=value pairs from a search form. Each is
        // its own store-id set; all must match (AND).
        $metaFilters = [];
        $meta = $request->get_param('meta');
        $metaOps = $request->get_param('metaop');
        $metaOps = is_array($metaOps) ? $metaOps : [];
        if (is_array($meta)) {
            // Each pair costs a query, and the map arrives from the query string,
            // so the count is capped and the keys restricted to fields that are
            // actually registered — an unknown key is a probe, not a filter.
            $filterable = $this->filterableMetaKeys();
            $applied = 0;
            foreach ($meta as $metaKey => $metaValue) {
                if ($applied >= self::MAX_META_FILTERS) {
                    break;
                }
                $metaKey = sanitize_key((string) $metaKey);
                $metaValue = $this->sanitizeText($metaValue);
                if ($metaKey === '' || $metaValue === '' || ! isset($filterable[$metaKey])) {
                    continue;
                }
                $metaFilters[] = $this->reads()->storeIdsForMeta($metaKey, $metaValue, $this->sanitizeMetaOp($metaOps[$metaKey] ?? null));
                $applied++;
            }
        }

        // Intersect the active filter sets into one allowed store-id list and
        // push it into SQL (whereIn), instead of hydrating everything and
        // filtering in PHP. null = no filters; [] = filters active but nothing
        // matched, so the search is empty.
        $allowedIds = $this->intersectFilters(
            $categoryFilter,
            $brandFilter,
            $termFilter,
            $metaFilters,
            $openFilter,
            [$locationConstraint, $fixedCategoryFilter, $fixedBrandFilter, $fixedTermFilter, $fixedMetaFilter]
        );
        // Same intersection WITHOUT the category constraint — the category facet
        // is category-independent (picking a tile mustn't change the other tiles'
        // counts), so it always counts over this set.
        $allowedNoCat = $this->intersectFilters(
            null,
            $brandFilter,
            $termFilter,
            $metaFilters,
            $openFilter,
            [$locationConstraint, $fixedCategoryFilter, $fixedBrandFilter, $fixedTermFilter, $fixedMetaFilter]
        );

        /**
         * Filter the intersected set of store ids a search is constrained to.
         * Return null for "no constraint", or an array of ids (possibly empty) to
         * restrict results — useful for geofencing, membership, or custom filters.
         *
         * @param int[]|null      $allowedIds Allowed store ids (null = unconstrained).
         * @param WP_REST_Request $request    The search request.
         */
        $allowedIds = apply_filters('cliq_sl_search_allowed_ids', $allowedIds, $request);

        if (is_array($allowedIds) && $allowedIds === []) {
            $empty = [
                'results' => [],
                'total' => 0,
                'page' => $page,
                'per_page' => $perPage,
                'unit' => $unit,
            ];
            // The results are empty, but the category facet is category-independent
            // — other categories may still have stores under the same radius/
            // filters, so compute it from $allowedNoCat rather than forcing zeros.
            $facet = $this->facetCategoryCounts($allowedNoCat, $hasGeo, $lat, $lng, $radius, $unit, $city, $country, null, $visibleStatuses);
            if ($facet !== null) {
                $empty['categoryCounts'] = $facet === [] ? (object) [] : $facet;
            }

            return new WP_REST_Response($empty, 200);
        }

        $offset = ($page - 1) * $perPage;
        $ordered = []; // store_id => ['distance' => float|null, 'location' => array]

        if ($hasGeo) {
            // Built on demand: the page and the total are two queries over the
            // same conditions, and the builder is stateful.
            $geoQuery = function () use ($city, $country, $allowedIds, $lat, $lng, $radius, $unit, $visibleStatuses) {
                $query = Location::query()->where('is_primary', '=', 1);
                $this->scopeToVisibleStores($query, $visibleStatuses);
                if ($city !== '') {
                    $query->where('city', '=', $city);
                }
                if ($country !== '') {
                    $query->where('country', '=', $country);
                }
                if ($allowedIds !== null) {
                    $query->whereIn('store_id', $allowedIds);
                }

                return $query->withinDistance((float) $lat, (float) $lng, $radius, $unit);
            };

            // withinDistance() already orders by distance in SQL, so the page is
            // taken there too: an anonymous request hydrates the rows it will
            // return, not every row inside the radius. Sorting by name needs the
            // whole set to reorder, so that path takes a ceiling instead — either
            // way one request has a bounded cost.
            $query = $geoQuery();
            $geoTotal = null;
            if ($sort === 'name') {
                $query->limit(self::MAX_GEO_ROWS);
            } else {
                $query->limit(min(self::MAX_GEO_ROWS, $offset + $perPage));
                // The page no longer reveals how many matched, so ask separately
                // rather than reporting the size of the slice as the total.
                $geoTotal = $geoQuery()->count();
            }

            foreach ($query->raw() as $row) {
                // Skip un-geocoded locations parked at (0,0) — they'd otherwise
                // surface as bogus matches for searches near the Gulf of Guinea.
                if ((float) $row['latitude'] === 0.0 && (float) $row['longitude'] === 0.0) {
                    continue;
                }
                $storeId = (int) $row['store_id'];
                if (!isset($ordered[$storeId])) {
                    $ordered[$storeId] = ['distance' => round((float) $row['distance'], 3), 'location' => $row];
                }
            }

            if ($sort === 'name') {
                $ordered = $this->orderSearchRowsByStoreName($ordered);
            }

            $total = $geoTotal ?? count($ordered);
            $pageIds = array_slice(array_keys($ordered), $offset, $perPage);
        } else {
            // Non-geo (browse): paginate in SQL so anonymous requests never load
            // the entire table into memory.
            $query = Location::query()->where('is_primary', '=', 1);
            $this->scopeToVisibleStores($query, $visibleStatuses);
            if ($city !== '') {
                $query->where('city', '=', $city);
            }
            if ($country !== '') {
                $query->where('country', '=', $country);
            }
            if ($allowedIds !== null) {
                $query->whereIn('store_id', $allowedIds);
            }

            $total = $query->count();

            // Name sorting has to happen before pagination. The location model
            // cannot order by a column in the stores table, so collect the
            // already-filtered primary-location rows and order their ids using
            // the store names. The default browse path remains SQL-paginated.
            $rows = [];
            if ($sort === 'name') {
                // The id list still comes from the filtered locations — the
                // location model cannot order by a column in the stores table —
                // but only the ids are kept, and the ordering AND the page are
                // taken in SQL, so an anonymous request never sorts or holds the
                // whole result set.
                $candidateIds = [];
                foreach ($query->raw() as $row) {
                    $candidateIds[(int) $row['store_id']] = true;
                }
                $pageIdsByName = $this->reads()->orderStoreIdsByName(
                    array_keys($candidateIds),
                    $perPage,
                    $offset
                );
                if ($pageIdsByName !== []) {
                    $pageQuery = Location::query()->where('is_primary', '=', 1)->whereIn('store_id', $pageIdsByName);
                    $this->scopeToVisibleStores($pageQuery, $visibleStatuses);
                    $byStore = [];
                    foreach ($pageQuery->raw() as $row) {
                        $byStore[(int) $row['store_id']] ??= $row;
                    }
                    foreach ($pageIdsByName as $id) {
                        if (isset($byStore[$id])) {
                            $rows[] = $byStore[$id];
                        }
                    }
                }
            } else {
                $orderedQuery = $query->orderBy('store_id', 'ASC');
                $orderedQuery->limit($perPage, $offset);
                $rows = $orderedQuery->raw();
            }
            foreach ($rows as $row) {
                $storeId = (int) $row['store_id'];
                if (!isset($ordered[$storeId])) {
                    $ordered[$storeId] = ['distance' => null, 'location' => $row];
                }
            }

            $pageIds = array_keys($ordered);
        }

        // Batch-load all per-store relations for this page so the format loop
        // below issues a fixed number of queries instead of ~5 per store.
        $this->formatter()->prefetch($pageIds);
        $storesById = $this->storesByIds($pageIds);

        $results = [];
        foreach ($pageIds as $id) {
            $store = $storesById[$id] ?? null;
            if (!$store) {
                continue;
            }
            $results[] = $this->formatter()->format($store, $ordered[$id]['location'], $ordered[$id]['distance']);
        }

        self::announceEvent(null, 'search', $term);

        $response = [
            'results' => $results,
            'total' => $total,
            'page' => $page,
            'per_page' => $perPage,
            'unit' => $unit,
        ];

        $response = self::enrichResults($response, $results);

        // Category facet: per-category counts over the WHOLE current match set,
        // MINUS the category filter (so the tiles reflect the active radius/
        // filters, and selecting a tile leaves the other tiles' counts stable).
        // Emitted whenever a narrowing is active — geo, or a filtered non-geo
        // browse; an unfiltered browse is skipped (its facet IS the global total,
        // so the client's static /categories counts are already correct). When no
        // category is selected the geo set is already resolved in $ordered, so
        // reuse it and avoid a second spatial query.
        $precomputed = ($categoryFilter === null && $hasGeo) ? array_map('intval', array_keys($ordered)) : null;
        $facet = $this->facetCategoryCounts($allowedNoCat, $hasGeo, $lat, $lng, $radius, $unit, $city, $country, $precomputed, $visibleStatuses);
        if ($facet !== null) {
            $response['categoryCounts'] = $facet === [] ? (object) [] : $facet;
        }

        /**
         * Filter the full search response payload before it's returned — add
         * aggregate data, re-rank results, inject facets, etc.
         *
         * @param array<string, mixed> $response The response body.
         * @param WP_REST_Request      $request  The search request.
         */
        $response = apply_filters('cliq_sl_search_results', $response, $request);

        return new WP_REST_Response($response, 200);
    }

    /** @param array<int, array{distance:float|null,location:array}> $rows */
    private function orderSearchRowsByStoreName(array $rows): array
    {
        $ordered = [];
        foreach ($this->reads()->orderStoreIdsByName(array_keys($rows)) as $id) {
            if (isset($rows[$id])) {
                $ordered[$id] = $rows[$id];
            }
        }

        return $ordered;
    }

    /**
     * Intersect the active filter sets (each a store_id => true map) into a flat
     * list of allowed store ids for an SQL whereIn. Returns null when no filter
     * is active, or an array (possibly empty) of ids when at least one is.
     *
     * @param array<int, true>|null               $categoryFilter
     * @param array<int, true>|null               $brandFilter
     * @param array<int, true>|null               $termFilter
     * @param array<int, array<int, true>>        $metaFilters
     * @param array<int, true>|null               $openFilter
     * @param array<int, array<int, true>|null>   $hardFilters immutable placement constraints
     *
     * @return int[]|null
     */
    private function intersectFilters(
        ?array $categoryFilter,
        ?array $brandFilter,
        ?array $termFilter,
        array $metaFilters,
        ?array $openFilter = null,
        array $hardFilters = []
    ): ?array {
        $sets = [];
        foreach ([$categoryFilter, $brandFilter, $termFilter, $openFilter, ...$hardFilters] as $set) {
            if ($set !== null) {
                $sets[] = $set;
            }
        }
        foreach ($metaFilters as $set) {
            $sets[] = $set;
        }

        if ($sets === []) {
            return null;
        }

        $allowed = null;
        foreach ($sets as $set) {
            $ids = array_keys($set);
            $allowed = $allowed === null ? $ids : array_values(array_intersect($allowed, $ids));
            if ($allowed === []) {
                return [];
            }
        }

        return $allowed ?? [];
    }

    public function store(WP_REST_Request $request): WP_REST_Response
    {
        $this->applyRequestLanguage($request);
        $id = (int) $request['id'];
        $store = Store::query()
            ->where('id', '=', $id)
            ->where('status', '=', 'active')
            ->whereNull('source_id')
            ->whereNull('deleted_at')
            ->first();

        if (!$store) {
            return new WP_REST_Response(['message' => __('Store not found.', 'cliq-store-locator')], 404);
        }

        return new WP_REST_Response($this->storePayload($store), 200);
    }

    /** Single store by slug — powers the store detail page. */
    public function storeBySlug(WP_REST_Request $request): WP_REST_Response
    {
        $this->applyRequestLanguage($request);
        $payload = $this->payloadForSlug((string) $request['slug']);

        if ($payload === null) {
            return new WP_REST_Response(['message' => __('Store not found.', 'cliq-store-locator')], 404);
        }

        return new WP_REST_Response($payload, 200);
    }

    /**
     * Build the full render-ready store payload for a slug (reused by the
     * server-rendered store detail page). Returns null when not found.
     *
     * @return array<string, mixed>|null
     */
    public function payloadForSlug(string $slug): ?array
    {
        $slug = sanitize_title($slug);
        $store = Store::query()
            ->where('slug', '=', $slug)
            ->where('status', '=', 'active')
            ->whereNull('source_id')
            ->whereNull('deleted_at')
            ->first();

        if (! $store) {
            $storeId = StoreDetailUrl::resolveStoreId($slug);
            $store = $storeId !== null
                ? Store::query()
                    ->where('id', '=', $storeId)
                    ->where('status', '=', 'active')
                    ->whereNull('source_id')
                    ->whereNull('deleted_at')
                    ->first()
                : null;
        }

        return $store ? $this->storePayload($store) : null;
    }

    /**
     * Build the full single-store payload (location, hours, all locations) and
     * log a view. Shared by the id and slug endpoints.
     *
     * @return array<string, mixed>
     */
    private function storePayload(Store $store): array
    {
        $payload = $this->renderPayload($store);
        self::announceEvent((int) $store->getKey(), 'view', null);

        return $payload;
    }

    /**
     * The full render-ready store payload (location, hours, all locations) WITHOUT
     * logging a view. This is the single source of truth for the shape designs are
     * rendered against — used by the detail page (via storePayload) AND the builder
     * preview, so the preview matches the live site token-for-token.
     *
     * @return array<string, mixed>
     */
    public function renderPayload(Store $store): array
    {
        $id = (int) $store->getKey();
        $formatter = $this->formatter();
        $location = $formatter->primaryLocation($id);
        $payload = $formatter->format($store, $location, null);
        $payload['hours'] = $location ? $formatter->hoursForLocation((int) $location['id']) : [];
        $payload['opening_hours'] = $payload['hours'];
        $payload['locations'] = array_map(
            fn (array $row): array => $formatter->formatLocation($row),
            Location::query()->where('store_id', '=', $id)->raw()
        );

        return $payload;
    }

    /**
     * First-page browse payloads in the EXACT shape the /search endpoint returns —
     * so the server-side (SEO) render produces store cards that match what the
     * React app shows once it hydrates. Mirrors the non-geo branch of search(),
     * including the optional default filters (country / category / brand / q) a
     * placement may set, so the SSR cards aren't unfiltered while the app filters.
     *
     * @param array<string, string> $filters country|category|brand|q|meta_key|meta_value|open|statuses
     *
     * @return array<int, array<string, mixed>>
     */
    public function browsePayloads(int $perPage, array $filters = []): array
    {
        return $this->browseResponse($perPage, $filters)['results'];
    }

    /**
     * First-page browse in the /search RESPONSE shape ({results, total, unit}) —
     * the server-side render inlines this as the app's bootstrap "search" payload,
     * so the hydrated list/count match the SSR page without a refetch. Mirrors
     * search()'s non-geo branch (SQL pagination + count for total).
     *
     * @param array<string, string> $filters country|category|brand|q|meta_key|meta_value|open|statuses
     *
     * @return array{results: array<int, array<string, mixed>>, total: int, unit: string, designs?: array<string, array<string, mixed>>}
     */
    public function browseResponse(int $perPage, array $filters = [], string $unit = 'km'): array
    {
        $perPage = $this->clampPerPage($perPage);
        $unit = strtolower($unit) === 'mi' ? 'mi' : 'km';

        $country = isset($filters['country']) ? $this->sanitizeText($filters['country']) : '';
        $categoryIds = $this->withCategoryDescendants($this->parseIdList($filters['category'] ?? null));
        $brandId = (int) ($filters['brand'] ?? 0);
        $term = isset($filters['q']) ? $this->sanitizeText($filters['q']) : '';
        $metaFilters = [];
        $metaKey = sanitize_key((string) ($filters['meta_key'] ?? ''));
        $metaValue = $this->sanitizeText($filters['meta_value'] ?? '');
        if ($metaKey !== '' && $metaValue !== '') {
            $metaFilters[] = $this->reads()->storeIdsForMeta($metaKey, $metaValue, '=');
        }
        // Keeps the SSR/browse bootstrap consistent with the live /search
        // endpoint's `open` param — otherwise first paint could show closed
        // stores even when the layout's Open-now toggle defaults on.
        $openFilter = ! empty($filters['open']) ? $this->reads()->storeIdsOpenNow() : null;
        // Language + status are UNCONDITIONAL, same as search() — the SSR/
        // browse bootstrap must never show a different set of stores than
        // the hydrated app would (see PublicController::search()).
        $visibleStatuses = $this->parseStatusList($filters['statuses'] ?? null);

        $allowedIds = $this->intersectFilters(
            $categoryIds !== [] ? $this->reads()->storeIdsForPivot('cliq_sl_store_category', 'category_id', $categoryIds) : null,
            $brandId > 0 ? $this->reads()->storeIdsForPivot('cliq_sl_brand_store', 'brand_id', $brandId) : null,
            $term !== '' ? $this->reads()->storeIdsForTerm($term) : null,
            $metaFilters,
            $openFilter
        );
        // The same intersection WITHOUT the category constraint — the category
        // facet must count the categories a visitor could still switch to, and
        // is what lets a Categories drill-down show only the categories present
        // in THIS placement (see below).
        $allowedNoCat = $this->intersectFilters(
            null,
            $brandId > 0 ? $this->reads()->storeIdsForPivot('cliq_sl_brand_store', 'brand_id', $brandId) : null,
            $term !== '' ? $this->reads()->storeIdsForTerm($term) : null,
            $metaFilters,
            $openFilter
        );
        if (is_array($allowedIds) && $allowedIds === []) {
            return $this->withBrowseFacet(['results' => [], 'total' => 0, 'unit' => $unit], $allowedNoCat, $unit, $country, $visibleStatuses);
        }

        $query = Location::query()->where('is_primary', '=', 1);
        $this->scopeToVisibleStores($query, $visibleStatuses);
        if ($country !== '') {
            $query->where('country', '=', $country);
        }
        if ($allowedIds !== null) {
            $query->whereIn('store_id', $allowedIds);
        }

        $total = $query->count();

        $ordered = []; // store_id => primary-location row
        $ordered_query = $query->orderBy('store_id', 'ASC');
        $ordered_query->limit($perPage, 0);
        foreach ($ordered_query->raw() as $row) {
            $storeId = (int) $row['store_id'];
            if (! isset($ordered[$storeId])) {
                $ordered[$storeId] = $row;
            }
        }

        $pageIds = array_keys($ordered);
        if ($pageIds === []) {
            return $this->withBrowseFacet(['results' => [], 'total' => $total, 'unit' => $unit], $allowedNoCat, $unit, $country, $visibleStatuses);
        }

        $this->formatter()->prefetch($pageIds);
        $storesById = $this->storesByIds($pageIds);

        $results = [];
        foreach ($pageIds as $id) {
            $store = $storesById[$id] ?? null;
            if (! $store) {
                continue;
            }
            $results[] = $this->formatter()->format($store, $ordered[$id], null);
        }

        $response = ['results' => $results, 'total' => $total, 'unit' => $unit];
        // Ship the category facet with the FIRST paint. Without it a Categories
        // drill-down renders every category server-side and then drops the empty
        // ones the moment /search answers — a visible flash of categories this
        // placement never had (e.g. a hotels-only locator briefly listing cafés).
        $response = $this->withBrowseFacet($response, $allowedNoCat, $unit, $country, $visibleStatuses);

        return self::enrichResults($response, $results);
    }

    /**
     * Announce something a visitor did with the locator.
     *
     * Nothing here records it; a feature that measures usage listens.
     *
     * @param int|null    $storeId the store involved, when there is one
     * @param string      $type    view | search | direction
     * @param string|null $term    the search term, for a search
     */
    private static function announceEvent(?int $storeId, string $type, ?string $term): void
    {
        do_action('cliq_sl_locator_event', $storeId, $type, $term);
    }

    /**
     * Let features add to a page of results.
     *
     * Somewhere to resolve, in one pass, whatever a store payload references.
     *
     * @param array<string, mixed>              $response
     * @param array<int, array<string, mixed>>  $results formatted store payloads
     *
     * @return array<string, mixed>
     */
    private static function enrichResults(array $response, array $results): array
    {
        /**
         * Filter a search/browse response before it is returned.
         *
         * @param array<string, mixed>             $response
         * @param array<int, array<string, mixed>> $results the formatted stores
         */
        $filtered = apply_filters('cliq_sl_results_response', $response, $results);

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

    /**
     * Batch-load store models for a page of ids in ONE query (keyed by id),
     * replacing a Store::find() per row — so search/browse stay O(1) queries no
     * matter how many stores match. Mirrors find()'s soft-delete scope.
     *
     * @param int[] $ids
     *
     * @return array<int, Store>
     */
    private function storesByIds(array $ids): array
    {
        $ids = array_values(array_unique(array_map('intval', $ids)));
        if ($ids === []) {
            return [];
        }

        $map = [];
        foreach (Store::query()->whereIn('id', $ids)->whereNull('deleted_at')->get() as $store) {
            $map[(int) $store->getKey()] = $store;
        }

        return $map;
    }

    /**
     * Builder preview list: a handful of stores in the EXACT frontend payload shape
     * so the design builder previews token-for-token what the live site renders.
     * Admin-only (the builder runs in wp-admin).
     */
    public function previewStores(WP_REST_Request $request): WP_REST_Response
    {
        $this->applyRequestLanguage($request);
        $perPage = $this->clampPerPage((int) $request->get_param('per_page'));

        $stores = Store::query()
            ->whereNull('source_id')
            ->orderBy('id', 'ASC')
            ->limit($perPage)
            ->get();

        $records = [];
        foreach ($stores as $store) {
            $records[] = $this->renderPayload($store);
        }

        return new WP_REST_Response(['records' => $records, 'total' => count($records)], 200);
    }

    public function categories(?WP_REST_Request $request = null): WP_REST_Response
    {
        if ($request) {
            $this->applyRequestLanguage($request);
        }
        $query = Category::query()
            ->where('is_active', '=', 1)
            ->orderBy('ordering', 'ASC');
        ContentTranslator::scopeOriginals($query);
        $rows = $query->raw();

        // Global per-category totals (each parent includes its descendants).
        $agg = $this->aggregateCategoryCounts($this->reads()->categoryStoreCounts());

        $categories = array_map(static function (array $row) use ($agg): array {
            $id = (int) $row['id'];
            return [
                'id' => $id,
                'name' => $row['name'],
                'slug' => $row['slug'],
                'parent_id' => (int) ($row['parent_id'] ?? 0),
                'icon' => $row['icon'] ?? null,
                'color' => $row['color'] ?? null,
                'count' => (int) ($agg[$id] ?? 0),
            ];
        }, $rows);

        $categories = ContentTranslator::overlay($categories, 'cliq_sl_categories', ['name'], 'id');

        // 'records'/'total' alias the same list under the generic shape the
        // builder's preview-record fetch expects (BuilderField.js: res.records
        // || res.items || …) — this endpoint doubles as the 'category_tile'
        // design's preview source. Existing consumers reading `categories`
        // (the live drill-down widget) are unaffected.
        return $this->cacheable(new WP_REST_Response([
            'categories' => $categories,
            'records' => $categories,
            'total' => count($categories),
        ], 200));
    }

    /**
     * Choice options for a search-form field: taxonomies, countries, or distinct
     * values from a supported core/custom store field.
     */
    public function fieldOptions(WP_REST_Request $request): WP_REST_Response
    {
        $source = $this->sanitizeText($request->get_param('source'));
        $key = sanitize_key((string) $request->get_param('key'));
        // Optional narrowing, currently a country code for city/state lists.
        $scope = sanitize_key((string) $request->get_param('scope'));

        return $this->cacheable(new WP_REST_Response(['options' => $this->optionsForSource($source, $key, $scope)], 200));
    }

    /**
     * Resolve a search-form field's dynamic choices by source — shared between
     * the /field-options endpoint and the server-side (SSR) form render, so
     * SSR-rendered selects show the same options the app would fetch.
     *
     * @return array<int, array{value: string, label: string}>
     */
    public function optionsForSource(string $source, string $key = '', string $scope = ''): array
    {
        global $wpdb;
        $options = [];

        if ($source === 'categories') {
            // A parent id in $key scopes the list to that branch. Without it a
            // placement showing one kind of business still offered every category
            // on the site — a pizzeria locator listing hotel categories. The
            // parent itself is excluded: it is a grouping, not a choice.
            $parentId = (int) $key;
            $rows = Category::query()->where('is_active', '=', 1)->orderBy('ordering', 'ASC')->raw();
            $descendants = $parentId > 0 ? $this->categoryDescendants($parentId, $rows) : null;
            foreach ($rows as $row) {
                if ($descendants !== null && ! isset($descendants[(int) $row['id']])) {
                    continue;
                }
                $options[] = ['value' => (string) $row['id'], 'label' => $row['name']];
            }
        } elseif ($source === 'brands') {
            $rows = $wpdb->get_results(
                "SELECT DISTINCT b.id, b.name
                FROM {$wpdb->prefix}cliq_sl_brands b
                INNER JOIN {$wpdb->prefix}cliq_sl_brand_store bs ON bs.brand_id = b.id
                INNER JOIN {$wpdb->prefix}cliq_sl_stores s ON s.id = bs.store_id
                WHERE s.status = 'active' AND s.deleted_at IS NULL AND s.source_id IS NULL
                ORDER BY b.name ASC",
                ARRAY_A
            ) ?: [];
            foreach ($rows as $row) {
                $options[] = ['value' => (string) $row['id'], 'label' => $row['name']];
            }
        } elseif ($source === 'countries') {
            $options = CountryOptions::all();
        } elseif ($source === 'store_field' && $key !== '') {
            $options = $this->storeFieldOptions($key, $scope);
        } elseif ($source === 'meta' && $key !== '') {
            $options = $this->metaFieldOptions($key);
        }

        return $options;
    }

    /**
     * Whether any of these designs contains a gallery token.
     *
     * One `LIKE` over the stored models rather than decoding each one: this
     * decides whether a page needs the gallery's browser code, and it is asked on
     * every render. The table may not exist — nothing here creates it — so the
     * query is written to cost nothing when it does not.
     *
     * @param array<int, int> $ids
     */
    public function designsContainGallery(array $ids): bool
    {
        $ids = array_values(array_filter(array_unique(array_map('intval', $ids)), static fn (int $id): bool => $id > 0));
        if ($ids === []) {
            return false;
        }

        global $wpdb;
        $table = SchemaInstaller::qualify('cliq_sl_designs');
        $placeholders = implode(',', array_fill(0, count($ids), '%d'));
        $sql = "SELECT 1 FROM %i WHERE id IN ({$placeholders}) AND model LIKE %s LIMIT 1";
        // The IN placeholders are generated above and every value is bound.
        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.NotPrepared
        $found = $wpdb->get_var($wpdb->prepare($sql, array_merge([$table], $ids, ['%"token":"gallery"%'])));

        return $found !== null;
    }

    /**
     * Ids beneath a category, at any depth, excluding the category itself.
     *
     * Built from rows already loaded by the caller so scoping a facet costs no
     * extra query.
     *
     * @param array<int, array<string, mixed>> $rows all active category rows
     *
     * @return array<int, true> id => true
     */
    private function categoryDescendants(int $parentId, array $rows): array
    {
        $childrenOf = [];
        foreach ($rows as $row) {
            $childrenOf[(int) ($row['parent_id'] ?? 0)][] = (int) $row['id'];
        }

        $found = [];
        $stack = $childrenOf[$parentId] ?? [];
        while ($stack !== []) {
            $id = array_pop($stack);
            if (isset($found[$id])) {
                continue;
            }
            $found[$id] = true;
            foreach ($childrenOf[$id] ?? [] as $child) {
                $stack[] = $child;
            }
        }

        return $found;
    }

    /**
     * Distinct non-empty values from an explicitly allowed core store/location
     * field. The whitelist is both the public contract and the SQL-injection
     * boundary: authored field names are never interpolated directly.
     *
     * @return array<int, array{value: string, label: string}>
     */
    private function storeFieldOptions(string $key, string $country = ''): array
    {
        global $wpdb;

        // Columns that make sense as a filter's choices. `phone`, `email` and
        // `website` are excluded: over an unauthenticated endpoint they are a
        // contact-harvesting route.
        $storeColumns = ['name', 'status'];
        $locationColumns = ['address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country'];
        if (in_array($key, $storeColumns, true)) {
            $table = $wpdb->prefix . 'cliq_sl_stores';
            $sql = $wpdb->prepare(
                "SELECT DISTINCT %i FROM %i
                WHERE %i IS NOT NULL AND TRIM(%i) <> ''
                AND status = 'active' AND deleted_at IS NULL AND source_id IS NULL
                ORDER BY %i ASC LIMIT 500",
                $key,
                $table,
                $key,
                $key,
                $key
            );
        } elseif (in_array($key, $locationColumns, true)) {
            // An optional country narrows the list. A city dropdown on a locator
            // that only shows one country was otherwise offering every city in
            // the database, most of which return nothing.
            $country = strtoupper(trim($country));
            if ($country !== '') {
                $sql = $wpdb->prepare(
                    "SELECT DISTINCT l.%i
                    FROM {$wpdb->prefix}cliq_sl_store_locations l
                    INNER JOIN {$wpdb->prefix}cliq_sl_stores s ON s.id = l.store_id
                    WHERE l.%i IS NOT NULL AND TRIM(l.%i) <> '' AND l.is_primary = 1
                    AND s.status = 'active' AND s.deleted_at IS NULL AND s.source_id IS NULL
                    AND (UPPER(l.country) = %s OR UPPER(s.country) = %s)
                    ORDER BY l.%i ASC LIMIT 500",
                    $key,
                    $key,
                    $key,
                    $country,
                    $country,
                    $key
                );
            } else {
                $sql = $wpdb->prepare(
                    "SELECT DISTINCT l.%i
                    FROM {$wpdb->prefix}cliq_sl_store_locations l
                    INNER JOIN {$wpdb->prefix}cliq_sl_stores s ON s.id = l.store_id
                    WHERE l.%i IS NOT NULL AND TRIM(l.%i) <> '' AND l.is_primary = 1
                    AND s.status = 'active' AND s.deleted_at IS NULL AND s.source_id IS NULL
                    ORDER BY l.%i ASC LIMIT 500",
                    $key,
                    $key,
                    $key,
                    $key
                );
            }
        } else {
            return [];
        }

        $values = $wpdb->get_col($sql) ?: []; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared in the allowlisted branches above.

        $countryLabels = [];
        if ($key === 'country') {
            // Not $country — that is this method's own parameter now, and reusing
            // the name here would quietly overwrite the caller's scope filter.
            foreach (CountryOptions::all() as $countryOption) {
                $countryLabels[strtoupper((string) $countryOption['value'])] = (string) $countryOption['label'];
            }
        }

        return array_map(static function ($value) use ($countryLabels): array {
            $value = trim((string) $value);
            return [
                'value' => $value,
                'label' => $countryLabels[strtoupper($value)] ?? $value,
            ];
        }, $values);
    }

    /**
     * Options for a custom-field search dropdown. Prefer the field's DEFINED
     * choices (config.options) so labels render correctly and multi-value
     * (checkbox/multiselect) fields don't leak their serialized storage; fall
     * back to the distinct stored values — decoded + flattened — only when the
     * field declares no choices (e.g. a free-text field made filterable).
     *
     * @return array<int, array{value: string, label: string}>
     */
    /**
     * Field types that offer a fixed set of choices, and so the only ones whose
     * values are worth listing.
     *
     * @var string[]
     */
    /** @var array<string, bool>|null resolved once per instance, not per process */
    private ?array $filterableMetaKeys = null;

    /** Kilometres. Beyond this a "search" is a request to scan the whole table. */
    private const MAX_RADIUS = 20000;

    /**
     * Location rows one geographic search may hydrate.
     *
     * The radius cap alone does not bound this — a global radius matches
     * everything — and this endpoint answers unauthenticated requests.
     */
    private const MAX_GEO_ROWS = 2000;

    /** Custom-field filters honoured per request; each one costs its own query. */
    private const MAX_META_FILTERS = 10;

    private const CHOICE_FIELD_TYPES = ['select', 'checkboxes', 'radio'];

    /**
     * Custom-field keys a search may filter on: the registered store fields.
     *
     * Not the render allowlist — a search form can filter on a field the result
     * card never displays — but not an open door either: an unregistered key
     * would let a caller probe for columns and spend a query doing it.
     *
     * @return array<string, bool>
     */
    /**
     * Scope a primary-location query to the stores a visitor may see.
     *
     * Language and status apply to EVERY search. Resolving them into id sets
     * meant each anonymous request built a PHP array the size of the store table
     * and sent it back as `IN (...)`; as a join they cost nothing to carry. The
     * optional filters — category, brand, term, custom fields — stay as id sets,
     * because those are selective and usually small.
     *
     * The store table is aliased so its columns can be named unambiguously; the
     * builder selects only the location table's own columns when a join is
     * present, so nothing here changes the shape of a returned row.
     *
     * @param \CliqThemes\ORM\Query\Builder $query
     * @param string[]                      $statuses
     */
    private function scopeToVisibleStores($query, array $statuses): void
    {
        $query->join('cliq_sl_stores AS cliqsl_s', 'store_id', '=', 'cliqsl_s.id');
        $query->where('cliqsl_s.lang', '=', Multilingual::current());
        $query->whereNull('cliqsl_s.source_id');
        $query->whereNull('cliqsl_s.deleted_at');
        if ($statuses !== []) {
            $query->whereIn('cliqsl_s.status', $statuses);
        }
    }

    private function filterableMetaKeys(): array
    {
        if (is_array($this->filterableMetaKeys)) {
            return $this->filterableMetaKeys;
        }

        $keys = [];
        if (class_exists(CustomFieldsRepository::class)) {
            foreach ((new CustomFieldsRepository())->forEntity('store') as $field) {
                $key = sanitize_key((string) ($field['field_key'] ?? ''));
                if ($key !== '') {
                    $keys[$key] = true;
                }
            }
        }

        return $this->filterableMetaKeys = $keys;
    }

    /**
     * Options for a custom-field facet.
     *
     * Unauthenticated, so it answers only for a field that exists, is a choice
     * field, and is referenced by one of the site's layouts. Anything else
     * returns nothing.
     *
     * @return array<int, array{value: string, label: string}>
     */
    private function metaFieldOptions(string $key): array
    {
        if (! isset(FrontendAssetRegistry::metaKeys()[$key])) {
            return [];
        }

        foreach ((new CustomFieldsRepository())->forEntity('store') as $field) {
            if ((string) ($field['field_key'] ?? '') !== $key) {
                continue;
            }
            if (! in_array((string) ($field['type'] ?? ''), self::CHOICE_FIELD_TYPES, true)) {
                return [];
            }

            $config = is_array($field['config'] ?? null) ? $field['config'] : [];
            $defined = [];
            foreach (($config['options'] ?? []) as $opt) {
                $value = (string) ($opt['value'] ?? '');
                if ($value !== '') {
                    $defined[] = ['value' => $value, 'label' => (string) ($opt['label'] ?? $value)];
                }
            }

            // Field found: use its authored choices, and only when it defines none
            // fall back to the values stores have actually selected. Either way,
            // stop — don't keep scanning the remaining fields.
            return $defined !== [] ? $defined : $this->distinctMetaOptions($key);
        }

        // No such field. A bare key in store_meta with no field behind it is
        // internal data, not a facet.
        return [];
    }

    /**
     * Distinct stored values for a meta key as options — decoding serialized
     * (multi-value) values and flattening them into individual, de-duplicated
     * scalar choices so a checkbox field never shows `a:1:{i:0;s:6:"dining";}`.
     *
     * @return array<int, array{value: string, label: string}>
     */
    private function distinctMetaOptions(string $key): array
    {
        global $wpdb;
        $values = $wpdb->get_col($wpdb->prepare(
            "SELECT DISTINCT sm.meta_value
            FROM {$wpdb->prefix}cliq_sl_store_meta sm
            INNER JOIN {$wpdb->prefix}cliq_sl_stores s ON s.id = sm.store_id
            WHERE sm.meta_key = %s AND sm.meta_value <> ''
            AND s.status = 'active' AND s.deleted_at IS NULL AND s.source_id IS NULL
            ORDER BY sm.meta_value ASC LIMIT 200",
            $key
        )) ?: [];

        $seen = [];
        foreach ($values as $stored) {
            $decoded = StoredValue::decode((string) $stored);
            foreach (is_array($decoded) ? $decoded : [$decoded] as $v) {
                if (is_scalar($v) && (string) $v !== '') {
                    $seen[(string) $v] = true;
                }
            }
        }

        $options = [];
        foreach (array_keys($seen) as $v) {
            $options[] = ['value' => (string) $v, 'label' => (string) $v];
        }

        return $options;
    }

    /**
     * List active locator-page templates (id + name) for the block editor picker.
     */
    public function templates(): WP_REST_Response
    {
        return $this->cacheable(new WP_REST_Response(
            ['templates' => LocatorLayouts::source()->all()],
            200
        ));
    }




    /**
     * A single locator template: its widget-grid model plus the referenced
     * list_item / infobox design models embedded, so the frontend can render the
     * whole page from one unauthenticated request.
     */
    public function template(WP_REST_Request $request): WP_REST_Response
    {
        $this->applyRequestLanguage($request);
        $payload = $this->templatePayload((int) $request['id']);

        if ($payload === null) {
            return new WP_REST_Response(['message' => __('Template not found.', 'cliq-store-locator')], 404);
        }

        return new WP_REST_Response($payload, 200);
    }

    /**
     * The /templates/{id} response body ({id, name, model, designs}) — shared
     * with the server-side (SSR) render, which inlines it as the app bootstrap.
     * null when the id isn't an existing locator template.
     *
     * @return array{id: int, name: mixed, model: array<string, mixed>, designs: array<string, array<string, mixed>>}|null
     */
    public function templatePayload(int $id): ?array
    {
        return LocatorLayouts::source()->find($id);
    }



















    /**
     * Anonymous result sets are always bounded. A template may still send
     * -1 for the former "show all" option; map that to the safe maximum.
     */
    private function clampPerPage(int $perPage): int
    {
        if ($perPage === -1) {
            return self::MAX_PER_PAGE;
        }
        if ($perPage <= 0) {
            return 20;
        }

        return min($perPage, self::MAX_PER_PAGE);
    }

    private function validCoordinates(mixed $lat, mixed $lng): bool
    {
        if (! is_numeric($lat) || ! is_numeric($lng)) {
            return false;
        }

        $latitude = (float) $lat;
        $longitude = (float) $lng;

        return $latitude >= -90.0 && $latitude <= 90.0
            && $longitude >= -180.0 && $longitude <= 180.0;
    }


    /**
     * A meta-filter comparison operator (metaop[key]) is a fixed, known-safe
     * enum, not free text — sanitize_text_field() would corrupt it: it HTML-
     * entity-encodes '<' as part of its tag-stripping (sanitize_text_field('<=')
     * === '&lt;='), silently breaking the '<=' operator into something that
     * matches none of storeIdsForMeta()'s strict === branches and falls back
     * to LIKE. Whitelist-validate instead. Defaults (and falls back for any
     * unrecognised value) to 'LIKE', matching the prior behaviour.
     */
    private function sanitizeMetaOp(mixed $value): string
    {
        if (!is_string($value)) {
            return 'LIKE';
        }
        $op = strtoupper(trim($value));

        return in_array($op, ['=', 'LIKE', 'IN', '>=', '<='], true) ? $op : 'LIKE';
    }

    /**
     * Parse a category filter value into a list of positive ids — a single id
     * ("5") or the frontend's optional multi-select filter ("5,12,30").
     *
     * @return array<int, int>
     */
    private function parseIdList(mixed $value): array
    {
        if ($value === null || $value === '') {
            return [];
        }
        $ids = array_map('absint', explode(',', (string) $value));

        return array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
    }

    /**
     * Expand category ids to include every active descendant. Selecting a parent
     * category (dropdown, chip, tile or "View all") then matches stores in its
     * sub-categories too — the same sub-tree its aggregated count advertises, so
     * the count and the search can never disagree ("shows 1 but no results").
     *
     * @param array<int, int> $ids
     * @return array<int, int>
     */
    private function withCategoryDescendants(array $ids): array
    {
        if ($ids === []) {
            return $ids;
        }

        // parent id => [child ids] across all active categories (one query).
        $query = Category::query()->where('is_active', '=', 1);
        ContentTranslator::scopeOriginals($query);
        $childrenMap = [];
        foreach ($query->raw() as $row) {
            $pid = (int) ($row['parent_id'] ?? 0);
            if ($pid > 0) {
                $childrenMap[$pid][] = (int) $row['id'];
            }
        }

        $seen = [];
        $stack = array_map('intval', $ids);
        while ($stack !== []) {
            $id = array_pop($stack);
            if (isset($seen[$id])) {
                continue;
            }
            $seen[$id] = true;
            foreach ($childrenMap[$id] ?? [] as $child) {
                $stack[] = $child;
            }
        }

        return array_keys($seen);
    }

    /**
     * Roll direct per-category store counts up the tree so each id's total
     * includes its own stores plus every descendant's. Shared by the
     * /categories payload (global totals) and the search category facet
     * (counts scoped to the current result set).
     *
     * @param array<int, int> $directCounts category id => direct store count
     * @return array<int, int> category id => aggregated (self + descendants) count
     */
    private function aggregateCategoryCounts(array $directCounts): array
    {
        $query = Category::query()->where('is_active', '=', 1);
        ContentTranslator::scopeOriginals($query);
        $childrenMap = [];
        $ids = [];
        foreach ($query->raw() as $row) {
            $id = (int) $row['id'];
            $ids[] = $id;
            $pid = (int) ($row['parent_id'] ?? 0);
            if ($pid > 0) {
                $childrenMap[$pid][] = $id;
            }
        }
        // A category whose parent is itself — or any parent cycle — would
        // otherwise recurse until the request runs out of memory, and this is a
        // PUBLIC endpoint. The visited set turns bad data into a wrong count
        // rather than a fatal; saving such a relationship is refused separately.
        $aggregate = static function (int $id, array $seen = []) use (&$aggregate, $directCounts, $childrenMap): int {
            if (isset($seen[$id])) {
                return 0;
            }
            $seen[$id] = true;
            $total = (int) ($directCounts[$id] ?? 0);
            foreach ($childrenMap[$id] ?? [] as $childId) {
                $total += $aggregate($childId, $seen);
            }
            return $total;
        };
        $out = [];
        foreach ($ids as $id) {
            $out[$id] = $aggregate($id);
        }

        return $out;
    }

    /**
     * Resolve the store-id set matching a search (minus the category filter) and
     * return aggregated per-category counts over it — the category-browser /
     * tiles facet. Returns null when there is no narrowing at all (an unfiltered
     * browse, whose facet equals the global totals the client already has).
     *
     * @param array<int, int>|null $allowedNoCat  Allowed store ids excluding the category filter (null = unconstrained).
     * @param int[]|null           $precomputedIds Already-resolved id set (e.g. a geo search's), to skip a second query.
     * @return array<int, int>|null category id => count
     */
    /**
     * Attach the category facet to a browse response.
     *
     * Every exit from browseResponse() needs it: the facet is what lets a
     * Categories drill-down paint only the categories this placement actually
     * contains, instead of rendering all of them and dropping the empty ones
     * once /search answers.
     *
     * @param array<string, mixed> $response
     * @param array<int, int>|null $allowedNoCat
     * @param array<int, string>   $statuses
     *
     * @return array<string, mixed>
     */
    private function withBrowseFacet(array $response, ?array $allowedNoCat, string $unit, string $country, array $statuses): array
    {
        $facet = $this->facetCategoryCounts($allowedNoCat, false, null, null, 0.0, $unit, '', $country, null, $statuses);
        if ($facet !== null) {
            $response['categoryCounts'] = $facet === [] ? (object) [] : $facet;
        }

        return $response;
    }

    private function facetCategoryCounts(?array $allowedNoCat, bool $hasGeo, mixed $lat, mixed $lng, float $radius, string $unit, string $city, string $country, ?array $precomputedIds, array $statuses = []): ?array
    {
        $facetIds = $precomputedIds;
        if ($facetIds === null) {
            if ($hasGeo) {
                $query = Location::query()->where('is_primary', '=', 1);
                $this->scopeToVisibleStores($query, $statuses);
                if ($city !== '') {
                    $query->where('city', '=', $city);
                }
                if ($country !== '') {
                    $query->where('country', '=', $country);
                }
                if ($allowedNoCat !== null) {
                    $query->whereIn('store_id', $allowedNoCat);
                }
                // Facet counts are a sidebar number, not the result set: the
                // same ceiling the search itself uses applies here, so an
                // anonymous request cannot turn a facet into a full scan.
                $ids = [];
                $rows = $query->withinDistance((float) $lat, (float) $lng, $radius, $unit)
                    ->limit(self::MAX_GEO_ROWS)
                    ->raw();
                foreach ($rows as $row) {
                    if ((float) $row['latitude'] === 0.0 && (float) $row['longitude'] === 0.0) {
                        continue;
                    }
                    $ids[(int) $row['store_id']] = true;
                }
                $facetIds = array_keys($ids);
            } else {
                // Always computed, never skipped: language and status scope every
                // browse, so "no optional filters" is not the same as "no
                // scoping". Before those moved into SQL this branch was reached
                // through $allowedNoCat always being non-null.
                $query = Location::query()->where('is_primary', '=', 1);
                $this->scopeToVisibleStores($query, $statuses);
                if ($city !== '') {
                    $query->where('city', '=', $city);
                }
                if ($country !== '') {
                    $query->where('country', '=', $country);
                }
                if ($allowedNoCat !== null) {
                    $query->whereIn('store_id', $allowedNoCat);
                }
                $facetIds = array_map(
                    static fn (array $r): int => (int) $r['store_id'],
                    $query->limit(self::MAX_GEO_ROWS)->raw()
                );
            }
        }

        return $this->aggregateCategoryCounts($this->reads()->categoryCountsForStores($facetIds));
    }

    /**
     * Parse a comma list of statuses (the Results widget's "Visible
     * statuses" setting, or a future REST override) — defaults to Active
     * only when absent/empty, matching the status field's own documented
     * intent ("Only Active stores appear on the public locator").
     *
     * @return string[]
     */
    private function parseStatusList(mixed $value): array
    {
        if ($value === null || $value === '') {
            return ['active'];
        }
        $publicStatuses = ['active', 'relocated', 'temporarily_closed', 'permanently_closed'];
        $statuses = array_map('sanitize_key', explode(',', (string) $value));
        $statuses = array_values(array_unique(array_filter(
            $statuses,
            static fn (string $status): bool => in_array($status, $publicStatuses, true)
        )));

        return $statuses !== [] ? $statuses : ['active'];
    }
}
