<?php

declare(strict_types=1);

namespace CliqThemes\StoreLocator\Support;

defined('ABSPATH') || exit;

use CliqThemes\StoreLocator\Models\Category;
use CliqThemes\StoreLocator\Models\Store;
use CliqThemes\StoreLocator\Repository\CustomFieldsRepository;

/**
 * Site-wide allowlist of assets referenced by Store Locator-owned data.
 *
 * Stored as a non-autoloaded option, never as a generated filesystem manifest.
 * It covers every record this plugin owns, and through
 * `cliq_sl_referenced_assets` whatever an installed feature owns, so filtering
 * and Load More cannot introduce an asset missing at page load.
 */
final class FrontendAssetRegistry
{
    public const OPTION = 'cliq_sl_frontend_assets';

    /** 2: added the custom-field (meta) allowlist. */
    public const SCHEMA_VERSION = 2;

    /**
     * Custom-field values the store payload always carries, whatever the layouts
     * reference: the schedule is read back out of it to build `hours`/`open_now`,
     * and the hero key selects a bundled fallback image.
     */
    public const CORE_META = ['opening_hours', 'hero_media'];

    /**
     * Icons used by renderer fallbacks when an authored value is omitted.
     *
     * The locator's own chrome — filter button, map-only toggle, list/split/map
     * switcher — defaults to an icon name that no design necessarily contains, so
     * the scan below would never find it and those controls rendered label-only.
     * Their defaults are listed here for that reason; keep in step with the
     * `|| 'name'` fallbacks in LocatorApp.js and widgets.js.
     */
    public const CORE_ICONS = [
        'map-pin', 'search', 'locate-fixed', 'chevron-left', 'chevron-right',
        'chevron-up', 'chevron-down', 'navigation', 'external-link', 'printer',
        'clock', 'star', 'phone', 'mail', 'globe', 'check',
        'sliders-horizontal', 'arrow-left', 'arrow-right', 'list', 'columns-2', 'map',
    ];

    /** @return array{schema_version:int,updated_at:int,icons:string[],markers:int[],meta:string[]} */
    public static function get(): array
    {
        $stored = get_option(self::OPTION, null);
        if (! self::valid($stored)) {
            return self::rebuild();
        }

        return $stored;
    }

    public static function invalidate(): void
    {
        // Lightweight unit tests exercise settings sanitisation without loading
        // WordPress's option API. Production always has delete_option().
        if (function_exists('delete_option')) {
            delete_option(self::OPTION);
        }
    }

    /**
     * Custom-field keys this site publishes to the frontend.
     *
     * The store payload is unauthenticated, and a site's custom fields are not
     * all meant for visitors, so a key earns its place by being referenced by a
     * saved design — the same thing as "some layout renders it".
     *
     * @return array<string, bool>
     */
    public static function metaKeys(): array
    {
        $stored = self::get();
        $keys = is_array($stored['meta'] ?? null) ? $stored['meta'] : [];

        /**
         * Filter the custom fields served to the frontend.
         *
         * @param string[] $keys custom-field keys included in the store payload
         */
        $keys = apply_filters('cliq_sl_public_meta_keys', $keys);

        return array_fill_keys(array_map('strval', is_array($keys) ? $keys : []), true);
    }

    /** @return array{schema_version:int,updated_at:int,icons:string[],markers:int[],meta:string[]} */
    public static function rebuild(): array
    {
        $icons = array_fill_keys(self::CORE_ICONS, true);
        $markers = [];
        $tokens = [];

        foreach (Category::query()->raw() as $row) {
            self::addIcon($icons, $row['icon'] ?? '');
        }
        foreach (Store::query()->raw() as $row) {
        }

        $settings = SettingsService::all();
        self::walk($settings, $icons, $markers, $tokens);

        /**
         * Filter the assets a feature's own records reference.
         *
         * @param array{icons: array<string,bool>, markers: array<int,bool>, tokens: array<string,bool>} $found
         */
        $found = apply_filters('cliq_sl_referenced_assets', [
            'icons' => $icons,
            'markers' => $markers,
            'tokens' => $tokens,
        ]);
        if (is_array($found)) {
            $icons = is_array($found['icons'] ?? null) ? $found['icons'] : $icons;
            $markers = is_array($found['markers'] ?? null) ? $found['markers'] : $markers;
            $tokens = is_array($found['tokens'] ?? null) ? $found['tokens'] : $tokens;
        }

        $payload = [
            'schema_version' => self::SCHEMA_VERSION,
            'updated_at' => time(),
            'icons' => array_values(array_keys($icons)),
            'markers' => array_values(array_map('intval', array_keys($markers))),
            'meta' => self::resolveMetaKeys($tokens),
        ];
        sort($payload['icons']);
        sort($payload['markers'], SORT_NUMERIC);
        sort($payload['meta']);
        update_option(self::OPTION, $payload, false);

        return $payload;
    }

    /**
     * Narrow the token/mapTo strings collected from the designs to real custom
     * fields.
     *
     * A design names a custom field by its bare key, indistinguishable from a
     * core token like `name` until checked against the registered fields.
     *
     * @param array<string,bool> $tokens
     *
     * @return string[]
     */
    private static function resolveMetaKeys(array $tokens): array
    {
        $keys = array_fill_keys(self::CORE_META, true);

        // A token does not always carry the key it reads. The Gallery token is
        // named `gallery` and its data lives in `meta.gallery_media`, so matching
        // token names alone stripped the images out of every gallery. The token
        // registry declares that mapping — follow it.
        foreach (DesignTokens::core() as $token) {
            $name = (string) ($token['value'] ?? '');
            $source = (string) ($token['source'] ?? '');
            if ($name !== '' && isset($tokens[$name]) && str_starts_with($source, 'meta.')) {
                $keys[substr($source, 5)] = true;
            }
        }

        if (! class_exists(CustomFieldsRepository::class)) {
            return array_keys($keys);
        }

        foreach ((new CustomFieldsRepository())->forEntity('store') as $field) {
            $key = (string) ($field['field_key'] ?? '');
            if ($key !== '' && isset($tokens[$key])) {
                $keys[$key] = true;
            }
        }

        return array_keys($keys);
    }

    /**
     * @param array<string,bool> $icons
     * @param array<int,bool>    $markers
     * @param array<string,bool> $tokens  candidate custom-field names, resolved
     *                                    against the registered fields later
     */
    public static function walk(array $value, array &$icons, array &$markers, array &$tokens = []): void
    {
        foreach ($value as $key => $child) {
            if (is_array($child)) {
                self::walk($child, $icons, $markers, $tokens);
                continue;
            }
            if (! is_scalar($child)) {
                continue;
            }

            $name = strtolower((string) $key);
            if ($name === 'icon' || str_ends_with($name, 'icon')) {
                self::addIcon($icons, $child);
            }
            if ($name === 'marker_id' || $name === 'markeroverrideid') {
            }
            // Three ways a design names a custom field, all of which count as
            // "this layout uses it": a displayed field's `token`, a facet's
            // `mapTo` prefixed with `meta:`, and a facet's explicit
            // `choicesMetaKey` (which the frontend prefers over `mapTo`).
            if ($name === 'token' || $name === 'choicesmetakey') {
                $tokens[sanitize_key((string) $child)] = true;
            }
            if ($name === 'mapto' && str_starts_with((string) $child, 'meta:')) {
                $tokens[sanitize_key(substr((string) $child, 5))] = true;
            }
        }
    }

    /** @param array<string,bool> $icons */
    private static function addIcon(array &$icons, mixed $value): void
    {
        $name = sanitize_key((string) $value);
        if ($name !== '') {
            $icons[$name] = true;
        }
    }

    /** @param array<int,bool> $markers */
    private static function addMarker(array &$markers, mixed $value): void
    {
        $id = (int) $value;
        if ($id > 0) {
            $markers[$id] = true;
        }
    }

    private static function valid(mixed $value): bool
    {
        return is_array($value)
            && (int) ($value['schema_version'] ?? 0) === self::SCHEMA_VERSION
            && is_array($value['icons'] ?? null)
            && is_array($value['markers'] ?? null)
            // A cache written before the allowlist existed has no `meta` and must
            // be rebuilt, not read: treating "absent" as "allow nothing" would
            // blank every custom field on the site until something saved a design.
            && is_array($value['meta'] ?? null);
    }
}
