<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Config;

use Numinix\SeekmodoWordPress\Mode\ModeFsm;

/**
 * Single point of truth for every `seekmodo_wp_*` `wp_options` row.
 *
 * Two flavors of consumer:
 *
 *   - Internal classes (Client, ModeFsm, AutoPromoter, …) read/write
 *     via the typed accessors here. Never call `get_option()` for a
 *     `seekmodo_wp_*` key from outside this class — it's the only
 *     place that knows the default + sanitize shape per option.
 *
 *   - Operators reading the storage layout directly (e.g. SSH'd into
 *     a tenant during incident response) can `wp option get
 *     seekmodo_wp_*` against the constant names below.
 *
 * Authority semantics: most options are local-first, but a subset
 * (mode, auto_promote, storefront_host) is overridable by the
 * `tenant.snapshot` pull from admin.seekmodo.com on a 5-min cadence
 * (see {@see RemoteConfig::pull()}). Locally-mutated keys in that
 * subset are restored on every successful pull, so `wp option update`
 * on those keys is informational at best and will be reverted within
 * 5 minutes. See `docs/CONFIGURATION.md`.
 */
final class Options
{
    public const OPT_TENANT_ID         = 'seekmodo_wp_tenant_id';
    public const OPT_SHARED_SECRET     = 'seekmodo_wp_shared_secret';
    /**
     * One-shot pairing handshake bookkeeping. Written by
     * {@see \Numinix\SeekmodoWordPress\Admin\ConnectFlow::initiate()}
     * when the operator clicks "Connect to Seekmodo" and burned by
     * {@see \Numinix\SeekmodoWordPress\Admin\ConnectFlow::handleCallback()}
     * when seekmodo.com POSTs the signed pairing JWT back. Stored as
     * options (not transients) so they survive an object-cache flush
     * between the redirect and the callback POST. Both rows are
     * created with autoload=false to keep them off the
     * wp_load_alloptions() hot path.
     */
    public const OPT_INSTALL_TOKEN     = 'seekmodo_wp_install_token';
    public const OPT_INSTALL_TOKEN_EXP = 'seekmodo_wp_install_token_exp';
    public const OPT_MODE              = 'seekmodo_wp_mode';
    public const OPT_AUTO_PROMOTE      = 'seekmodo_wp_auto_promote';
    public const OPT_GATEWAY_URL       = 'seekmodo_wp_gateway_url';
    public const OPT_INDEXABLE_TYPES   = 'seekmodo_wp_indexable_post_types';
    /**
     * Editorial post-type priority order applied client-side after
     * the gateway returns IDs but before SearchSwap hydrates them.
     *
     * Stored as a comma-separated string (e.g. `"page,post"`) so the
     * value round-trips cleanly through `wp option update` and the
     * Settings page without needing an array serializer. Empty value
     * means no re-rank -- the gateway's relevance order flows through
     * untouched.
     *
     * Resolution + bucket-sort logic lives in
     * {@see \Numinix\SeekmodoWordPress\Content\PostTypePriority}. The
     * `seekmodo_post_type_priority` filter overrides the option for
     * site-specific mu-plugins.
     *
     * Added v0.3.4 to fix the trek.ca search-ranking complaint:
     * blog posts were dominating top-of-results for high-intent
     * queries like "PPC" while service-detail pages (which live under
     * post_type=page on a /services/ URL hierarchy) got buried.
     * Setting `page,post` lifts every page above every blog post
     * while preserving Typesense's textual relevance order within
     * each tier.
     */
    public const OPT_POST_TYPE_PRIORITY = 'seekmodo_wp_post_type_priority';
    public const OPT_STOREFRONT_HOST   = 'seekmodo_wp_storefront_host';
    public const OPT_HEALTH_METRICS    = 'seekmodo_wp_health_metrics';
    public const OPT_BREAKER_STATE     = 'seekmodo_wp_breaker_state';
    public const OPT_INDEXER_CURSOR             = 'seekmodo_wp_indexer_cursor';
    public const OPT_SCHEMA_VERSION             = 'seekmodo_wp_schema_version';
    /**
     * Per-vertical schema-version marker for the commerce indexer
     * (added v0.3.0). `OPT_SCHEMA_VERSION` is the content-vertical
     * equivalent and continues to track {@see SCHEMA_VERSION}.
     * Splitting them so a commerce-only doc-shape bump doesn't force
     * an unnecessary content full reindex (and vice versa).
     */
    public const OPT_COMMERCE_SCHEMA_VERSION    = 'seekmodo_wp_commerce_schema_version';
    public const OPT_LAST_SNAPSHOT_AT           = 'seekmodo_wp_last_snapshot_at';
    /**
     * Comma-separated list of verticals the gateway has enabled for
     * this tenant — stored as a string ("commerce" or "commerce,content")
     * so wp_options round-trips cleanly without an array serializer.
     *
     * Refreshed on every {@see RemoteConfig::pull()} from the
     * `verticals` field on `tenant.snapshot`. {@see Bootstrap::boot()}
     * reads this option and conditionally registers Content* /
     * Commerce* sub-modules so a commerce-only tenant doesn't push
     * content-shaped docs at the gateway (which would route them into
     * the products Typesense collection and trigger a schema-mismatch
     * 4xx).
     *
     * Default value (empty string) is treated as "all-enabled" by
     * `Bootstrap::boot()` to preserve pre-v0.3.0 behavior on stale
     * snapshots — a freshly-installed plugin that hasn't completed
     * its first 5-min config-pull yet runs both verticals exactly the
     * way it did before this gate landed. The first snapshot pull
     * narrows the set immediately.
     */
    public const OPT_ENABLED_VERTICALS          = 'seekmodo_wp_enabled_verticals';
    /**
     * Master opt-in for the "Powered by Seekmodo" badge on the storefront.
     *
     * Owned by the gateway (`numinix_mcp_tenant_config.show_seekmodo_branding`)
     * and pulled down on every 5-min `tenant.snapshot` cadence — see
     * {@see RemoteConfig::applyOverrides()}. Defaults to `0` for every
     * tenant; partner sites and explicit-deal tenants flip it on from
     * the admin.seekmodo.com tenant settings page. The boolean is then
     * exposed to themes / plugins via the
     * {@see \numinix_seekmodo_show_branding()} helper plus the
     * `seekmodo_show_branding` filter — themes never read this option
     * directly.
     */
    public const OPT_SHOW_BRANDING     = 'seekmodo_wp_show_branding';

    /** Mirrored from tenant.snapshot.suggest_layout (migration 071). */
    public const OPT_SUGGEST_LAYOUT = 'seekmodo_wp_suggest_layout';

    /** Computed on gateway from subscription + show_seekmodo_branding. */
    public const OPT_SUGGEST_SHOW_BRANDING = 'seekmodo_wp_suggest_show_branding';

    /**
     * Per-site opt-in for the connector-bundled typeahead dropdown UI
     * (added v0.4.0). Defaults `0` so a fresh install on a site that
     * already ships its own typeahead (e.g. trek.ca's
     * `inc/seekmodo-search.php`) doesn't suddenly start rendering a
     * second dropdown on top of the existing one.
     *
     * Operators flip this on in wp-admin → Settings → Seekmodo when
     * they want the connector to provide the search-as-you-type UX
     * for them — POCO.com is the canonical "no theme integration,
     * just turn it on" customer. The
     * {@see \Numinix\SeekmodoWordPress\Frontend\TypeaheadUI} class
     * gates the asset enqueue on this option plus the
     * `seekmodo_enqueue_typeahead` filter (themes can force-off
     * site-wide regardless of the option).
     */
    public const OPT_TYPEAHEAD_UI_ENABLED = 'seekmodo_wp_typeahead_ui_enabled';

    /**
     * v0.6.0 (SM-606) — controls which storefront search-as-you-type
     * surface ships when {@see OPT_TYPEAHEAD_UI_ENABLED} is on.
     *
     *   - `0` (default) — universal `<seekmodo-suggest>` web component
     *     bundle. Richer envelope: keywords + products + categories +
     *     recent + trending + did_you_mean + "View all N results" CTA,
     *     all from one `/v1/suggest` round-trip.
     *   - `1` (opt-out) — legacy flat-row vanilla-JS dropdown that
     *     v0.4.0 → v0.5.x shipped. Same JS asset, same
     *     `/v1/typeahead` endpoint. Kept for one major-version cycle so
     *     a site with bespoke CSS tied to the legacy dropdown markup
     *     can defer the swap until they restyle.
     *
     * Operator-facing label: "Use legacy flat-row typeahead instead of
     * the universal suggest widget". Independent of
     * {@see OPT_TYPEAHEAD_UI_ENABLED}: leaving the master toggle off
     * suppresses both surfaces.
     */
    /** Mirrored from tenant.snapshot.ask_ai_enabled (migration 075). */
    public const OPT_ASK_AI_ENABLED = 'seekmodo_wp_ask_ai_enabled';

    /**
     * Auto-inject + REST proxy for PDP/cart recommendation cascades.
     * Default on; mirrored from tenant.snapshot.recommendations_enabled
     * when present. Themes can force off via
     * `seekmodo_recommendations_enabled` filter.
     */
    public const OPT_RECOMMENDATIONS_ENABLED = 'seekmodo_wp_recommendations_enabled';

    public const OPT_SUGGEST_LEGACY = 'seekmodo_wp_suggest_legacy';

    public const TRANSIENT_TENANT_SNAPSHOT = 'seekmodo_wp_tenant_snapshot';

    /** Cached `fallback_bundle` from tenant.snapshot (Tier 2 Enhanced Native). */
    public const OPT_FALLBACK_BUNDLE = 'seekmodo_wp_fallback_bundle';

    // 2 — Content/Indexer field shape aligned with the gateway's
    // Typesense `content` collection schema. Renames `content_plain`
    // → `body`, `permalink` → `url`, `date_published_unix` →
    // `published_at`, `author_id` (int) → `author` (string), flattens
    // `taxonomies` (nested map) → `tags` (string[]). Bumping this
    // constant auto-promotes the next `seekmodo_wp_index_tick` to a
    // full reindex via `Content/Indexer::tick()`, so an upgraded site
    // re-emits every previously-indexed post under the new shape
    // without an operator intervention. See `Content/Indexer::buildDoc()`.
    public const SCHEMA_VERSION = 2;

    // 1 — Commerce/ProductFields doc shape aligned with the gateway's
    // Typesense `commerce` collection schema (v0.3.0). Mirror of the
    // v0.1.1 / SCHEMA_VERSION=2 fix, but for the commerce vertical:
    // `title` → `name`, `permalink` → `url`, `stock_status` (string)
    // → `in_stock` (bool), `categories` (string[]) → (`category_id`
    // (int32[]) + `category_breadcrumbs` (string[])); drops the
    // schema-incompatible `excerpt`, `content_plain`, `attributes`,
    // `variations`, `regular_price`, `extra` legs (storefront
    // re-resolves variations / attrs at render time via
    // `wc_get_product()`). Adds three Woo-driven optional fields
    // (`on_sale`, `sale_price`, `currency`) backed by the companion
    // gateway migration
    // `services/mcp-gateway/ops/add-woocommerce-commerce-fields.php`.
    // See `Commerce/ProductFields::buildDoc()`.
    //
    // The commerce constant starts at 1 (not "follow the content
    // counter") so the upgrade-time auto-promote is gated on the
    // commerce-specific stored marker
    // {@see OPT_COMMERCE_SCHEMA_VERSION}. Pre-v0.3.0 installs have
    // `seekmodo_wp_commerce_schema_version` either missing (default
    // 0) or never written, so the first cron tick after upgrading
    // detects mismatch and full-reindexes products under the new
    // shape — no operator action.
    public const COMMERCE_SCHEMA_VERSION = 1;

    /**
     * Wire register_setting() for each option that's surfaced in the
     * admin UI. The Settings page reads + writes via the Settings API
     * (which honors sanitize_callback below); CLI / Connect flow write
     * directly via update_option() and skip the Settings API layer.
     */
    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }

        add_action('admin_init', static function (): void {
            if (!function_exists('register_setting')) {
                return;
            }
            register_setting('seekmodo_wp', self::OPT_GATEWAY_URL, [
                'type'              => 'string',
                'sanitize_callback' => static fn ($v) => self::sanitizeUrl((string) $v),
                'default'           => 'https://mcp.seekmodo.com',
            ]);
            register_setting('seekmodo_wp', self::OPT_MODE, [
                'type'              => 'string',
                'sanitize_callback' => static fn ($v) => self::sanitizeMode((string) $v),
                'default'           => ModeFsm::MODE_OFF,
            ]);
            register_setting('seekmodo_wp', self::OPT_INDEXABLE_TYPES, [
                'type'              => 'array',
                'sanitize_callback' => static fn ($v) => self::sanitizePostTypes($v),
                'default'           => [],
            ]);
            register_setting('seekmodo_wp', self::OPT_POST_TYPE_PRIORITY, [
                'type'              => 'string',
                'sanitize_callback' => static fn ($v) => self::sanitizePostTypePriority($v),
                'default'           => '',
            ]);
            register_setting('seekmodo_wp', self::OPT_TYPEAHEAD_UI_ENABLED, [
                'type'              => 'boolean',
                'sanitize_callback' => static fn ($v) => self::sanitizeBool($v),
                'default'           => '0',
            ]);
            register_setting('seekmodo_wp', self::OPT_SUGGEST_LEGACY, [
                'type'              => 'boolean',
                'sanitize_callback' => static fn ($v) => self::sanitizeBool($v),
                'default'           => '0',
            ]);
            register_setting('seekmodo_wp', self::OPT_SHOW_BRANDING, [
                'type'              => 'boolean',
                'sanitize_callback' => static fn ($v) => self::sanitizeBool($v),
                'default'           => '0',
            ]);
        });
    }

    /**
     * Idempotent seeding for fresh activation. Never overwrites a
     * non-null existing value — re-running this on a paired tenant is
     * a safe no-op.
     */
    public static function seedDefaults(): void
    {
        if (function_exists('add_option')) {
            add_option(self::OPT_MODE, ModeFsm::MODE_OFF);
            add_option(self::OPT_AUTO_PROMOTE, '1');
            add_option(self::OPT_GATEWAY_URL, 'https://mcp.seekmodo.com');
            add_option(self::OPT_BREAKER_STATE, ['state' => 'closed', 'failures' => [], 'opened_at' => 0]);
            add_option(self::OPT_HEALTH_METRICS, []);
            add_option(self::OPT_SCHEMA_VERSION, self::SCHEMA_VERSION);
            add_option(self::OPT_COMMERCE_SCHEMA_VERSION, self::COMMERCE_SCHEMA_VERSION);
            add_option(self::OPT_STOREFRONT_HOST, DomainLock::currentHost());
            // Default to *not* showing the "Powered by Seekmodo" badge.
            // The gateway-side toggle (admin.seekmodo.com) flips this to
            // '1' on partner / specific-deal tenants and the next
            // `tenant.snapshot` pull mirrors the change locally.
            add_option(self::OPT_SHOW_BRANDING, '0');
            add_option(self::OPT_RECOMMENDATIONS_ENABLED, '1');
        }
    }

    // -------------------------------------------------------------------
    // Typed accessors
    // -------------------------------------------------------------------

    public static function tenantId(): string
    {
        return (string) get_option(self::OPT_TENANT_ID, '');
    }

    public static function sharedSecret(): string
    {
        return (string) get_option(self::OPT_SHARED_SECRET, '');
    }

    public static function gatewayUrl(): string
    {
        $stored = (string) get_option(self::OPT_GATEWAY_URL, '');
        if ($stored !== '') {
            return rtrim($stored, '/');
        }

        return rtrim(defined('SEEKMODO_WP_GATEWAY_URL') ? SEEKMODO_WP_GATEWAY_URL : 'https://mcp.seekmodo.com', '/');
    }

    public static function isPaired(): bool
    {
        return self::tenantId() !== '' && self::sharedSecret() !== '';
    }

    public static function autoPromoteEnabled(): bool
    {
        $v = get_option(self::OPT_AUTO_PROMOTE, '1');
        if (is_bool($v)) {
            return $v;
        }

        return in_array(strtolower((string) $v), ['1', 'true', 'yes', 'on'], true);
    }

    public static function storefrontHost(): string
    {
        $stored = (string) get_option(self::OPT_STOREFRONT_HOST, '');
        if ($stored !== '') {
            return strtolower($stored);
        }

        return strtolower(DomainLock::currentHost());
    }

    /**
     * Master "show Powered by Seekmodo badge" flag. Source of truth is
     * the gateway (`numinix_mcp_tenant_config.show_seekmodo_branding`);
     * the value is mirrored locally by {@see RemoteConfig::applyOverrides()}
     * every 5 min. Default is `false` — the badge stays hidden unless the
     * operator explicitly enables it for a partner / specific-deal tenant.
     *
     * Themes / plugins should call {@see \numinix_seekmodo_show_branding()}
     * (which wraps this and applies the `seekmodo_show_branding` filter)
     * rather than reading this directly.
     */
    public static function showBranding(): bool
    {
        $v = get_option(self::OPT_SHOW_BRANDING, '0');
        if (is_bool($v)) {
            return $v;
        }

        return in_array(strtolower((string) $v), ['1', 'true', 'yes', 'on'], true);
    }

    public static function suggestLayout(): string
    {
        $allowed = ['cinema-grid', 'split-rail', 'command-bar', 'magazine', 'classic'];
        $v = strtolower(trim((string) get_option(self::OPT_SUGGEST_LAYOUT, 'split-rail')));
        if ($v === '' || !in_array($v, $allowed, true)) {
            return 'split-rail';
        }

        return $v;
    }

    public static function suggestShowBranding(): bool
    {
        $v = get_option(self::OPT_SUGGEST_SHOW_BRANDING, '0');
        if (is_bool($v)) {
            return $v;
        }

        return in_array(strtolower((string) $v), ['1', 'true', 'yes', 'on'], true);
    }

    public static function askAiEnabled(): bool
    {
        $v = get_option(self::OPT_ASK_AI_ENABLED, '0');
        if (is_bool($v)) {
            return $v;
        }

        return in_array(strtolower((string) $v), ['1', 'true', 'yes', 'on'], true);
    }

    /**
     * Whether PDP/cart recommendation cascades (and the REST proxy)
     * are armed. Defaults true so a paired Woo storefront gets
     * fail-soft empty placements when the plan returns 402.
     */
    public static function recommendationsEnabled(): bool
    {
        $v = get_option(self::OPT_RECOMMENDATIONS_ENABLED, '1');
        if (is_bool($v)) {
            $on = $v;
        } else {
            $on = in_array(strtolower((string) $v), ['1', 'true', 'yes', 'on'], true);
        }
        if (function_exists('apply_filters')) {
            return (bool) apply_filters('seekmodo_recommendations_enabled', $on);
        }

        return $on;
    }

    /**
     * Verticals the gateway has enabled for this tenant. Drives the
     * Bootstrap-time decision to register Content* / Commerce*
     * sub-modules — keeps a commerce-only tenant from pushing content
     * docs into a products Typesense collection.
     *
     * Returned as an associative array of `vertical => true` for
     * O(1) `isset()` lookups.
     *
     * Stale-snapshot fallback (option missing, empty string, or only
     * whitespace) is the all-enabled set: ['content' => true,
     * 'commerce' => true]. This preserves pre-v0.3.0 behavior on a
     * connector that hasn't completed its first config-pull, and on
     * a gateway that pre-dates the `verticals` field on
     * tenant.snapshot.
     *
     * Recognised vertical labels are 'content' and 'commerce'.
     * Unknown labels in the stored value are ignored — keeps a
     * future gateway that adds e.g. 'kb' from accidentally
     * activating a non-existent connector module here.
     *
     * @return array<string,true>
     */
    public static function enabledVerticals(): array
    {
        $stored = trim((string) get_option(self::OPT_ENABLED_VERTICALS, ''));
        if ($stored === '') {
            return ['content' => true, 'commerce' => true];
        }
        $set = [];
        foreach (explode(',', $stored) as $label) {
            $label = strtolower(trim($label));
            if ($label === 'content' || $label === 'commerce') {
                $set[$label] = true;
            }
        }
        if ($set === []) {
            return ['content' => true, 'commerce' => true];
        }

        return $set;
    }

    /**
     * Persist the pair returned by the Connect-to-Seekmodo round-trip.
     * Idempotent — re-running rotates credentials in place.
     */
    public static function persistPair(string $tenant_id, string $shared_secret): void
    {
        update_option(self::OPT_TENANT_ID, sanitize_text_field($tenant_id));
        update_option(self::OPT_SHARED_SECRET, sanitize_text_field($shared_secret));
    }

    // -------------------------------------------------------------------
    // Sanitize
    // -------------------------------------------------------------------

    private static function sanitizeUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return 'https://mcp.seekmodo.com';
        }
        if (!preg_match('#^https?://#i', $url)) {
            $url = 'https://' . $url;
        }

        return rtrim(esc_url_raw($url), '/');
    }

    private static function sanitizeMode(string $mode): string
    {
        $mode = strtolower(trim($mode));

        return in_array($mode, ModeFsm::ALL_MODES, true) ? $mode : ModeFsm::MODE_OFF;
    }

    /**
     * @param mixed $value
     * @return array<int,string>
     */
    private static function sanitizePostTypes($value): array
    {
        if (is_string($value)) {
            $decoded = json_decode($value, true);
            if (is_array($decoded)) {
                $value = $decoded;
            } else {
                $value = preg_split('/\s*,\s*/', trim($value)) ?: [];
            }
        }
        if (!is_array($value)) {
            return [];
        }
        $clean = array_filter(array_map(static function ($v): string {
            return preg_replace('/[^a-z0-9_\-]/i', '', (string) $v) ?? '';
        }, $value));

        return array_values(array_unique($clean));
    }

    /**
     * Generic truthy/falsy sanitizer — accepts the same set of
     * tokens the typed accessors return for, so a `wp option update
     * seekmodo_wp_show_branding "yes"` round-trips cleanly through
     * the Settings API checkbox path.
     *
     * @param mixed $value
     */
    private static function sanitizeBool($value): string
    {
        if (is_bool($value)) {
            return $value ? '1' : '0';
        }
        $s = strtolower(trim((string) $value));

        return in_array($s, ['1', 'true', 'yes', 'on'], true) ? '1' : '0';
    }

    /**
     * Normalise the post-type priority order. Accepts either a CSV
     * string or an array; returns a CSV string of lowercase
     * post-type slugs in priority order, deduplicated. Stored as a
     * string so wp_options round-trips cleanly without an array
     * serializer.
     *
     * @param mixed $value
     */
    private static function sanitizePostTypePriority($value): string
    {
        if (is_array($value)) {
            $list = $value;
        } elseif (is_string($value)) {
            $list = preg_split('/\s*,\s*/', trim($value)) ?: [];
        } else {
            return '';
        }
        $clean = [];
        foreach ($list as $v) {
            $slug = strtolower((string) $v);
            $slug = preg_replace('/[^a-z0-9_\-]/', '', $slug) ?? '';
            if ($slug !== '' && !in_array($slug, $clean, true)) {
                $clean[] = $slug;
            }
        }
        return implode(',', $clean);
    }
}
