<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Support;

defined('ABSPATH') || exit;

/**
 * Central settings store for the plugin, persisted as a single autoloaded
 * option. Provides typed defaults, sanitisation and a stable public shape so
 * the admin UI, REST API and frontend all read the same configuration.
 */
final class SettingsService
{
    public const OPTION = 'cliq_sl_settings';

    /**
     * Secret settings that must never be echoed back to the admin in clear. The
     * browser Maps key (google_maps_api_key) is deliberately excluded — it is
     * already public in page output, so masking it would only break the UI.
     *
     * @var string[]
     */
    /**
     * Settings that are never echoed back to the admin in clear.
     *
     * The browser Maps key is absent: it is already public in page output.
     *
     * @var string[]
     */
    private const CORE_SECRET_KEYS = ['google_server_api_key'];

    /**
     * @return string[]
     */
    public static function secretKeys(): array
    {
        /**
         * Filter which settings are masked when read back.
         *
         * @param string[] $keys
         */
        $keys = apply_filters('cliq_sl_secret_settings', self::CORE_SECRET_KEYS);

        return array_values(array_unique(array_filter(array_map('strval', is_array($keys) ? $keys : []))));
    }

    /** Placeholder returned in place of a stored secret. */
    public const SECRET_MASK = '********';

    /**
     * @return array<string, mixed>
     */
    public static function defaults(): array
    {
        $defaults = [
            // Maps
            'map_provider' => 'leaflet', // leaflet | google
            'google_maps_api_key' => '', // browser key (Maps JS + Places autocomplete)
            'google_maps_api_loaded_externally' => false,
            'google_server_api_key' => '', // server key (Geocoding API, server-side only)
            'geocoder' => 'nominatim', // nominatim | google
            'map_default_lat' => 51.5074,
            'map_default_lng' => -0.1278,
            'map_default_zoom' => 5,
            'map_type' => 'roadmap', // roadmap | satellite | hybrid | terrain (Google)
            'clicked_zoom' => 13,
            'search_zoom' => 12,
            'enable_clustering' => true,
            'cluster_style' => 'soft', // See cliq-ui CLUSTER_STYLES (includes glass-rings + stacked).
            'cluster_color' => '#3858e9',
            'marker_animation' => 'none', // none | bounce | pulse | drop
            'map_style' => 'standard', // preset key (see mapStyles.js) or 'custom'
            'map_style_custom' => '', // JSON style array when map_style = 'custom'
            'marker_size' => 40, // custom marker icon height in px (width scales to keep aspect)
            'radius_circle' => false, // draw the search radius as a circle on the map
            'mouse_scroll' => true,

            // Search / distance. Form structure, distance control/options,
            // sorting and paging all live on the locator's widgets — only true
            // site-wide behaviour belongs here.
            'distance_unit' => 'mi', // mi | km
            'restrict_country' => '', // ISO-2 list, comma separated (Google Places scope)
            'enable_autocomplete' => true,

            // Frontend display. Layout/arrangement is entirely the locator layout's
            // template; only the delivery mode is a setting:
            //   lazy-load   — mount point only; the React app fetches everything (default).
            //   server-side — pre-render the first page of store cards into the HTML for
            //                 SEO, then the React app boots and replaces it (hybrid SSR).
            'rendering_mode' => 'lazy-load', // lazy-load | server-side
            // Restrict public icon/marker data to assets referenced anywhere in
            // Store Locator-owned designs and records.
            'optimize_frontend_assets' => false,

            // Global frontend fallback theme. Per-element Builder styles remain
            // more specific and therefore take priority over these tokens.
            'theme_font_family' => '',
            'theme_font_loaded_by_theme' => false,
            'theme_primary' => '#3858e9',
            'theme_primary_strong' => '#1d4ed8',
            'theme_on_primary' => '#ffffff',
            'theme_canvas' => '#ffffff',
            'theme_surface' => '#ffffff',
            'theme_text' => '#1e293b',
            'theme_heading' => '#0f172a',
            'theme_text_soft' => '#334155',
            'theme_label' => '#475569',
            'theme_muted' => '#64748b',
            'theme_faint' => '#94a3b8',
            'theme_border' => '#d5d8dc',
            'theme_border_soft' => '#e2e8f0',
            'theme_divider' => '#f1f5f9',
            'theme_danger' => '#b91c1c',
            'theme_danger_bg' => '#fef2f2',

            // Behaviour
            'keep_data_on_uninstall' => false,
            'store_detail_pages' => true,
            'store_detail_page' => 0,   // base Page id for single-store URLs
            // Ordered core fields used to build every public store-detail URL.
            // A store's editable database slug also resolves as its own route.
            'store_detail_slug_fields' => ['city', 'name'],
            // Locator template (purpose='locator') rendered by a no-ID placement.
            // Zero means "no saved layout": the bundled default is resolved at
            // render time instead. Once an editable layout exists this holds its id.
            // Status assigned to stores submitted through the registration form.


            // Active form/layout per role. These point at rows in the forms /
            // layouts collections (0 = none). A placement (block/shortcode) can
            // override by id; otherwise the relevant default here is used.
            // Global-default marker fallback, used when neither a store nor its
            // category has one assigned — icon (markers table) and HTML
            // (designs table, purpose='marker') are independent, per markerType.

            // The bundled locator's appearance and wording.
            'locator_map_position' => 'right', // left | right | none
            // Map share of the locator's 12-column content row. The store list
            // receives the remaining columns.
            'locator_map_columns' => 7,
            'locator_list_columns' => 3,
            'locator_list_min_width' => 260,
            'locator_map_height' => 560,
            'locator_results_per_page' => 20,
            'locator_show_load_more' => true,
            'locator_load_more_label' => 'Load more',
            // Empty contact labels preserve the real value. When configured,
            // the label becomes linked text while the destination stays intact.
            'locator_phone_label' => '',
            'locator_email_label' => '',
            'locator_website_label' => '',
            'locator_directions_button_label' => 'Directions',
            'locator_details_button_label' => 'View More',
            'locator_location_label' => 'Location',
            'locator_location_placeholder' => 'City, postcode or address',
            'locator_category_label' => 'Category',
            'locator_distance_label' => 'Distance',
            'locator_search_button_label' => 'Search',
            'locator_result_count_label' => 'Stores',
            'locator_empty_results_message' => 'No stores found.',
            'locator_show_search' => true,
            'locator_show_category_filter' => true,
            'locator_show_radius_filter' => true,
            'locator_show_result_count' => true,
            'locator_show_print' => false,
            'locator_detail_show_hero' => true,
            'locator_card_fields' => [
                ['field' => 'logo', 'enabled' => true, 'icon' => ''],
                ['field' => 'name', 'enabled' => true, 'icon' => ''],
                ['field' => 'address', 'enabled' => true, 'icon' => 'map-pin'],
                ['field' => 'category', 'enabled' => false, 'icon' => 'tag'],
                ['field' => 'brand', 'enabled' => false, 'icon' => 'tag'],
                ['field' => 'brand_logo', 'enabled' => false, 'icon' => ''],
                ['field' => 'phone', 'enabled' => true, 'icon' => 'phone'],
                ['field' => 'email', 'enabled' => false, 'icon' => 'mail'],
                ['field' => 'website', 'enabled' => true, 'icon' => ''],
                ['field' => 'opening_hours', 'enabled' => true, 'icon' => 'clock'],
                ['field' => 'distance', 'enabled' => true, 'icon' => ''],
                ['field' => 'details', 'enabled' => true, 'icon' => 'arrow-right'],
            ],
            'locator_card_columns' => 1,
            'locator_infobox_fields' => [
                ['field' => 'logo', 'enabled' => true, 'icon' => ''],
                ['field' => 'name', 'enabled' => true, 'icon' => ''],
                ['field' => 'address', 'enabled' => true, 'icon' => 'map-pin'],
                ['field' => 'category', 'enabled' => false, 'icon' => 'tag'],
                ['field' => 'phone', 'enabled' => false, 'icon' => 'phone'],
                ['field' => 'email', 'enabled' => false, 'icon' => 'mail'],
                ['field' => 'website', 'enabled' => false, 'icon' => ''],
                ['field' => 'directions', 'enabled' => true, 'icon' => 'arrow-right'],
                ['field' => 'details', 'enabled' => false, 'icon' => 'chevron-right'],
            ],
            'locator_detail_fields' => [
                ['field' => 'logo', 'enabled' => true, 'icon' => ''],
                ['field' => 'name', 'enabled' => true, 'icon' => ''],
                ['field' => 'address', 'enabled' => true, 'icon' => 'map-pin'],
                ['field' => 'category', 'enabled' => false, 'icon' => 'tag'],
                ['field' => 'brand', 'enabled' => false, 'icon' => 'tag'],
                ['field' => 'brand_logo', 'enabled' => false, 'icon' => ''],
                ['field' => 'description', 'enabled' => true, 'icon' => ''],
                ['field' => 'phone', 'enabled' => true, 'icon' => 'phone'],
                ['field' => 'email', 'enabled' => true, 'icon' => 'mail'],
                ['field' => 'website', 'enabled' => true, 'icon' => ''],
                ['field' => 'opening_hours', 'enabled' => true, 'icon' => ''],
            ],
            'locator_detail_columns' => 1,
            'locator_enable_clustering' => true,
            'locator_default_zoom' => 5,
            'locator_marker_preset' => 'store',

            // Builder preview device widths (px). Drive the responsive device toggle
            // by the bundled layout; editable in Settings.
            'device_width_desktop' => 1280,
            'device_width_tablet' => 768,
            'device_width_mobile' => 390,
        ];

        /**
         * Register custom settings + their default values. Integrators adding a
         * setting key here will have it persisted/returned alongside the built-ins.
         *
         * @param array<string, mixed> $defaults
         */
        return apply_filters('cliq_sl_settings_defaults', $defaults);
    }

    /**
     * @return array<string, mixed>
     */
    public static function all(): array
    {
        $stored = get_option(self::OPTION, []);
        if (! is_array($stored)) {
            $stored = [];
        }

        $defaults = self::defaults();
        $all = array_merge($defaults, $stored);

        // Themes saved before the canvas token existed must remain visually
        // coherent. In particular, a dark surface cannot be paired with the
        // new light default canvas merely because the key was not stored yet.
        if (! array_key_exists('theme_canvas', $stored)) {
            $all['theme_canvas'] = (string) ($stored['theme_surface'] ?? $defaults['theme_canvas']);
        }

        // Older settings may contain complete template arrays saved before
        // Hero Image became available in cards and infoboxes. A shallow settings
        // merge would otherwise replace the new defaults with those older arrays.
        foreach (['locator_card_fields', 'locator_infobox_fields', 'locator_detail_fields'] as $key) {
            $configured = is_array($all[$key] ?? null) ? $all[$key] : [];
            $hasHero = array_filter(
                $configured,
                static fn (mixed $item): bool => is_array($item) && ($item['field'] ?? '') === 'logo'
            ) !== [];
            if (! $hasHero) {
                array_unshift($configured, ['field' => 'logo', 'enabled' => true, 'icon' => '']);
            }
            if ($key === 'locator_card_fields') {
                $hasOpeningHours = array_filter(
                    $configured,
                    static fn (mixed $item): bool => is_array($item) && ($item['field'] ?? '') === 'opening_hours'
                ) !== [];
                if (! $hasOpeningHours) {
                    $configured[] = [
                        'field' => 'opening_hours',
                        'enabled' => true,
                        'icon' => 'clock',
                    ];
                }
            }
            $all[$key] = $configured;
        }

        return $all;
    }

    /**
     * Like {@see all()} but with secret values replaced by a mask, for safe
     * return to the admin UI. Pair with {@see update()}, which ignores a
     * submitted secret still equal to the mask (i.e. left unchanged).
     *
     * @return array<string, mixed>
     */
    public static function allMasked(): array
    {
        $all = self::all();
        foreach (self::secretKeys() as $key) {
            if (! empty($all[$key])) {
                $all[$key] = self::SECRET_MASK;
            }
        }

        return $all;
    }

    public static function get(string $key, mixed $fallback = null): mixed
    {
        $all = self::all();

        return $all[$key] ?? $fallback;
    }

    /**
     * Merge and persist a partial set of settings after sanitising.
     *
     * @param array<string, mixed> $values
     *
     * @return array<string, mixed> the full settings after update
     */
    public static function update(array $values): array
    {
        // A secret submitted still equal to the mask means "unchanged" — drop it
        // so the stored value is preserved rather than overwritten with the mask.
        foreach (self::secretKeys() as $key) {
            if (isset($values[$key]) && $values[$key] === self::SECRET_MASK) {
                unset($values[$key]);
            }
        }

        $current = self::all();
        $clean = self::sanitize($values);
        $merged = array_merge($current, $clean);

        // autoload=false: this is a large blob (90+ keys incl. builder JSON) and
        // is read on demand (REST/SSR), so it must not load on every page.
        update_option(self::OPTION, $merged, false);
        FrontendAssetRegistry::invalidate();

        return $merged;
    }

    /**
     * @param array<string, mixed> $values
     *
     * @return array<string, mixed>
     */
    public static function sanitize(array $values): array
    {
        $defaults = self::defaults();
        $clean = [];

        foreach ($values as $key => $value) {
            if (! array_key_exists($key, $defaults)) {
                continue; // ignore unknown keys
            }

            $default = $defaults[$key];

            if (is_bool($default)) {
                $clean[$key] = (bool) $value;
            } elseif (is_int($default)) {
                $clean[$key] = (int) $value;
            } elseif (is_float($default)) {
                $clean[$key] = (float) $value;
            } elseif (is_array($default)) {
                // Builder JSON (rows/columns/items): accept structured data,
                // deep-sanitising scalar leaves.
                $clean[$key] = is_array($value) ? self::sanitizeDeep($value) : $default;
            } else {
                $clean[$key] = sanitize_text_field((string) $value);
            }
        }

        // Constrain enumerated values.
        if (isset($clean['map_provider']) && ! in_array($clean['map_provider'], ['leaflet', 'google'], true)) {
            $clean['map_provider'] = 'leaflet';
        }
        if (isset($clean['geocoder']) && ! in_array($clean['geocoder'], ['nominatim', 'google'], true)) {
            $clean['geocoder'] = 'nominatim';
        }
        if (isset($clean['distance_unit']) && ! in_array($clean['distance_unit'], ['km', 'mi'], true)) {
            $clean['distance_unit'] = 'mi';
        }
        if (isset($clean['rendering_mode']) && ! in_array($clean['rendering_mode'], ['lazy-load', 'server-side'], true)) {
            $clean['rendering_mode'] = 'lazy-load';
        }
        if (isset($clean['locator_map_position']) && ! in_array($clean['locator_map_position'], ['left', 'right', 'none'], true)) {
            $clean['locator_map_position'] = 'right';
        }
        if (isset($clean['locator_map_columns'])) {
            $clean['locator_map_columns'] = max(4, min(9, (int) $clean['locator_map_columns']));
        }
        if (isset($clean['locator_list_columns'])) {
            $clean['locator_list_columns'] = max(1, min(6, (int) $clean['locator_list_columns']));
        }
        if (isset($clean['locator_list_min_width'])) {
            $clean['locator_list_min_width'] = max(160, min(600, (int) $clean['locator_list_min_width']));
        }
        foreach (['locator_card_columns', 'locator_detail_columns'] as $columnSetting) {
            if (isset($clean[$columnSetting])) {
                $clean[$columnSetting] = max(1, min(2, (int) $clean[$columnSetting]));
            }
        }
        if (isset($clean['locator_marker_preset']) && ! in_array($clean['locator_marker_preset'], ['store', 'home', 'info', 'car'], true)) {
            $clean['locator_marker_preset'] = 'store';
        }
        if (isset($clean['locator_map_height'])) {
            $clean['locator_map_height'] = max(240, min(1200, $clean['locator_map_height']));
        }
        if (isset($clean['locator_results_per_page'])) {
            $clean['locator_results_per_page'] = max(1, min(100, $clean['locator_results_per_page']));
        }
        if (isset($clean['locator_load_more_label'])) {
            $clean['locator_load_more_label'] = substr(trim((string) $clean['locator_load_more_label']), 0, 80);
        }
        foreach (
            [
            'locator_phone_label',
            'locator_email_label',
            'locator_website_label',
            'locator_directions_button_label',
            'locator_details_button_label',
            'locator_location_label',
            'locator_location_placeholder',
            'locator_category_label',
            'locator_distance_label',
            'locator_search_button_label',
            'locator_result_count_label',
            'locator_empty_results_message',
            ] as $labelSetting
        ) {
            if (isset($clean[$labelSetting])) {
                $clean[$labelSetting] = substr(trim((string) $clean[$labelSetting]), 0, 80);
            }
        }
        if (isset($clean['locator_default_zoom'])) {
            $clean['locator_default_zoom'] = max(1, min(22, $clean['locator_default_zoom']));
        }
        if (isset($clean['map_type']) && ! in_array($clean['map_type'], ['roadmap', 'satellite', 'hybrid', 'terrain'], true)) {
            $clean['map_type'] = 'roadmap';
        }
        $slugFields = ['name', 'city', 'state', 'postal_code', 'country'];
        if (isset($clean['store_detail_slug_fields'])) {
            $clean['store_detail_slug_fields'] = array_values(array_unique(array_filter(
                $clean['store_detail_slug_fields'],
                static fn (mixed $field): bool => is_string($field) && in_array($field, $slugFields, true)
            )));
            if ($clean['store_detail_slug_fields'] === []) {
                $clean['store_detail_slug_fields'] = ['city', 'name'];
            }
        }
        $templateFields = [
            'locator_card_fields' => ['logo', 'name', 'address', 'category', 'brand', 'brand_logo', 'phone', 'email', 'website', 'opening_hours', 'distance', 'details'],
            'locator_infobox_fields' => ['logo', 'name', 'address', 'category', 'phone', 'email', 'website', 'directions', 'details'],
            'locator_detail_fields' => ['logo', 'name', 'address', 'category', 'brand', 'brand_logo', 'description', 'phone', 'email', 'website', 'opening_hours'],
        ];
        $templateIcons = [
            '', 'phone', 'smartphone', 'map-pin', 'wifi', 'twitter',
            'facebook', 'instagram', 'whatsapp', 'arrow-right',
            'chevron-right', 'tag', 'mail', 'globe', 'link', 'external-link',
            'clock',
        ];
        foreach ($templateFields as $settingKey => $allowedFields) {
            if (! isset($clean[$settingKey])) {
                continue;
            }
            $configured = [];
            foreach ($clean[$settingKey] as $item) {
                if (! is_array($item)) {
                    continue;
                }
                $field = (string) ($item['field'] ?? '');
                $isCustom = str_starts_with($field, 'custom:')
                    && preg_match('/^custom:[a-z0-9_-]+$/', $field) === 1;
                if ((! in_array($field, $allowedFields, true) && ! $isCustom) || isset($configured[$field])) {
                    continue;
                }
                $icon = (string) ($item['icon'] ?? '');
                $configured[$field] = [
                    'field' => $field,
                    'enabled' => (bool) ($item['enabled'] ?? false),
                    'icon' => in_array($icon, $templateIcons, true) ? $icon : '',
                    'column' => (int) ($item['column'] ?? 1) === 2 ? 2 : 1,
                ];
            }
            foreach ($allowedFields as $field) {
                if (! isset($configured[$field])) {
                    $configured[$field] = ['field' => $field, 'enabled' => false, 'icon' => '', 'column' => 1];
                }
            }
            $clean[$settingKey] = array_values($configured);
        }
        if (isset($clean['marker_size'])) {
            $clean['marker_size'] = max(12, min(120, (int) $clean['marker_size']));
        }
        foreach (
            [
            'theme_primary', 'theme_primary_strong', 'theme_on_primary',
            'theme_canvas', 'theme_surface', 'theme_text', 'theme_heading', 'theme_text_soft',
            'theme_label', 'theme_muted', 'theme_faint', 'theme_border',
            'theme_border_soft', 'theme_divider', 'theme_danger', 'theme_danger_bg',
            ] as $colorKey
        ) {
            if (isset($clean[$colorKey])) {
                $color = sanitize_hex_color((string) $clean[$colorKey]);
                $clean[$colorKey] = $color ?: (string) $defaults[$colorKey];
            }
        }
        // Real store statuses only (the public locator shows Active stores).
        if (isset($clean['registration_status']) && ! in_array($clean['registration_status'], ['pending', 'active'], true)) {
            $clean['registration_status'] = 'pending';
        }

        /**
         * Final pass over the sanitised settings — lets integrators sanitise or
         * constrain custom keys they registered via `cliq_sl_settings_defaults`.
         *
         * @param array<string, mixed> $clean  Sanitised values.
         * @param array<string, mixed> $values Raw submitted values.
         */
        return apply_filters('cliq_sl_settings_sanitize', $clean, $values);
    }

    /**
     * Recursively sanitise a structured (array) setting, cleaning scalar
     * leaves and preserving array shape.
     *
     * @param array<mixed> $value
     *
     * @return array<mixed>
     */
    private static function sanitizeDeep(array $value): array
    {
        $clean = [];
        foreach ($value as $k => $v) {
            $key = is_string($k) ? sanitize_text_field($k) : $k;
            if (is_array($v)) {
                $clean[$key] = self::sanitizeDeep($v);
            } elseif (is_bool($v)) {
                $clean[$key] = $v;
            } elseif (is_int($v) || is_float($v)) {
                $clean[$key] = $v;
            } else {
                $clean[$key] = sanitize_text_field((string) $v);
            }
        }

        return $clean;
    }
}
