<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Frontend;

use Numinix\SeekmodoWordPress\Bootstrap;
use Numinix\SeekmodoWordPress\Config\Options;
use Numinix\SeekmodoWordPress\Gateway\BrowserToken;
use Numinix\SeekmodoWordPress\Rest\Routes;

/**
 * v0.6.0 (SM-606) — universal `<seekmodo-suggest>` web-component
 * storefront enqueue.
 *
 * Lifecycle:
 *
 *   1. `wp_head` emits `<meta name="seekmodo:tenant|token|gateway|
 *      refresh">` so the bundled SDK can resolve the page's tenant +
 *      gateway-direct credentials.
 *   2. `wp_enqueue_scripts` enqueues the static IIFE bundle shipped at
 *      `assets/suggest/seekmodo-suggest.global.js`. The bundle
 *      auto-registers `customElements.define("seekmodo-suggest", ...)`
 *      on load.
 *   3. A tiny inline autoboot script walks the selector list (same
 *      defaults as the legacy `TypeaheadUI`) and inserts a
 *      `<seekmodo-suggest input="…">` sibling after each input. The
 *      bundle handles debounce, abort, LRU cache, keyboard nav, click
 *      attribution, and the rich `/v1/suggest` envelope render itself
 *      — the connector's only job is wiring.
 *
 * Why a sibling element instead of the legacy
 * `data-seekmodo-typeahead`-by-selector auto-attach:
 *
 *   - The `<seekmodo-suggest>` element supports `<slot>` overrides and
 *     CSS Custom Properties through the Shadow DOM boundary, so themes
 *     can swap any block's rendering by dropping a `<div slot="…">`
 *     child *inside* the element. A pure CSS-selector auto-attach
 *     pattern doesn't expose a place for that slot content.
 *   - Per-input customization (e.g. WC cart-page coupon field has its
 *     own `<seekmodo-suggest blocks="products" view-all-href="…">`)
 *     is straightforward once each input has its own element.
 *
 * Gating:
 *
 *   - {@see Options::OPT_TYPEAHEAD_UI_ENABLED} master toggle (same as
 *     legacy {@see TypeaheadUI}). Default OFF — operators opt in.
 *   - {@see Options::OPT_SUGGEST_LEGACY} flips back to the legacy
 *     v0.4.0 flat-row dropdown for one major-version cycle. Default
 *     `0` so a v0.6.0 upgrade-and-toggle-on lands the universal
 *     widget by default.
 *   - `seekmodo_enqueue_suggest` filter — `__return_false` suppresses
 *     site-wide regardless of options (themes with bespoke
 *     `<seekmodo-suggest>` placement set the meta tags themselves and
 *     opt-out of the autoboot).
 *
 * Extensions points:
 *
 *   - `seekmodo_suggest_selectors`, mirrors
 *     `seekmodo_typeahead_selectors` — CSS-selector list the autoboot
 *     walks.
 *   - `seekmodo_suggest_blocks` — CSV of blocks the widget should
 *     render in order. Default matches the gateway's WS-2 default
 *     ("recent,did_you_mean,keywords,trending,products,categories").
 *   - `seekmodo_suggest_view_all_href` — URL template for the
 *     "View all N results" CTA. Default `/?s={q}` (WP core search).
 *     WooCommerce sites are detected and default to
 *     `/?s={q}&post_type=product`.
 *   - `seekmodo_suggest_extra_attrs` — assoc array of extra attrs
 *     forwarded to every spawned `<seekmodo-suggest>` (e.g.
 *     `min-length`, `debounce-ms`, `limit`, `cache-size`, `lang`).
 *   - `seekmodo_suggest_config` — last-resort filter on the localized
 *     JS config object.
 */
final class SuggestUI
{
    public const SCRIPT_HANDLE = 'seekmodo-suggest';
    public const CORS_HANDLE   = 'seekmodo-cors-notice';
    public const META_HANDLE   = 'seekmodo-suggest-meta';

    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }
        add_action('wp_enqueue_scripts', [self::class, 'enqueue']);
        add_action('wp_head', [self::class, 'renderMeta'], 1);
        add_filter('script_loader_tag', [self::class, 'suggestScriptTag'], 10, 3);
    }

    /**
     * Add `onerror` to the suggest bundle tag so a blocked cross-origin
     * script load surfaces the inline CORS notice helper.
     */
    public static function suggestScriptTag(string $tag, string $handle, string $src): string
    {
        if ($handle !== self::SCRIPT_HANDLE || $tag === '') {
            return $tag;
        }
        $onerror = ' onerror="window.seekmodoScriptLoadFailed&amp;&amp;window.seekmodoScriptLoadFailed()"';

        return str_replace(' src=', $onerror . ' src=', $tag);
    }

    /**
     * Whether the suggest widget should ship on this request. Returns
     * true when the master toggle is on AND the legacy override is off
     * AND the request is a public-facing one (not admin/RSS/etc).
     */
    public static function isActive(): bool
    {
        if (function_exists('is_admin') && is_admin()) {
            return false;
        }
        if (!self::masterEnabled()) {
            return false;
        }
        if (self::legacyForced()) {
            return false;
        }
        $on = (bool) apply_filters('seekmodo_enqueue_suggest', true);

        return $on;
    }

    /**
     * Meta tags. Emitted first thing in `<head>` so the SDK singleton
     * inside the bundle resolves config on first access even when the
     * bundle is loaded with `defer` / `async`.
     */
    public static function renderMeta(): void
    {
        if (!self::isActive()) {
            return;
        }
        $tenant = Options::tenantId();
        if ($tenant === '') {
            // Not paired yet — emit nothing. Bundle bails gracefully
            // when the tenant meta is absent.
            return;
        }
        $gateway = self::gatewayUrl();
        $refresh = self::refreshUrl();
        $bt      = BrowserToken::current();
        $token   = is_array($bt) && isset($bt['token']) ? (string) $bt['token'] : '';

        $emit = static function (string $name, string $content): void {
            if ($content === '') {
                return;
            }
            printf(
                "<meta name=\"%s\" content=\"%s\">\n",
                esc_attr($name),
                esc_attr($content)
            );
        };
        $emit('seekmodo:tenant',  $tenant);
        $emit('seekmodo:gateway', $gateway);
        $emit('seekmodo:refresh', $refresh);
        // Inline token only when available — otherwise the bundle hits
        // the refresh URL on first call. Both paths are correct; the
        // inline path saves one round-trip on the first keystroke.
        $emit('seekmodo:token',   $token);
    }

    public static function enqueue(): void
    {
        if (!self::isActive()) {
            return;
        }
        if (!function_exists('wp_register_script') || !function_exists('wp_enqueue_script')) {
            return;
        }
        $version = defined('SEEKMODO_WP_VERSION') ? (string) SEEKMODO_WP_VERSION : false;
        $base    = defined('SEEKMODO_WP_PLUGIN_URL') ? (string) SEEKMODO_WP_PLUGIN_URL : '';

        wp_register_script(
            self::CORS_HANDLE,
            $base . 'assets/suggest/seekmodo-cors-notice.js',
            [],
            $version,
            true
        );
        wp_enqueue_script(self::CORS_HANDLE);

        wp_register_script(
            self::SCRIPT_HANDLE,
            $base . 'assets/suggest/seekmodo-suggest.global.js',
            [self::CORS_HANDLE],
            $version,
            // Footer + defer-compatible. The bundle self-registers
            // `customElements.define()` on load; the inline autoboot
            // below depends on the bundle being defined before it runs.
            true
        );
        wp_enqueue_script(self::SCRIPT_HANDLE);

        // Inline autoboot — walks the selector list and inserts a
        // `<seekmodo-suggest>` sibling after each matching input. Same
        // contract the legacy TypeaheadUI autoboot used, just retargeted
        // at the web component. Tiny payload (~600 B uncompressed) so
        // we don't enqueue a second JS file.
        $config = self::config();
        $config = apply_filters('seekmodo_suggest_config', $config);
        $boot   = self::buildAutobootScript($config);
        if ($boot !== '') {
            wp_add_inline_script(self::SCRIPT_HANDLE, $boot);
        }

        // Enhanced Native degradation: REST typeahead when suggest gateway fails.
        if (function_exists('numinix_seekmodo_enhanced_native_enabled')
            && numinix_seekmodo_enhanced_native_enabled()) {
            $taBase = defined('SEEKMODO_WP_PLUGIN_URL') ? (string) SEEKMODO_WP_PLUGIN_URL : '';
            $taVer  = defined('SEEKMODO_WP_VERSION') ? (string) SEEKMODO_WP_VERSION : false;
            wp_register_script(
                'seekmodo-typeahead-fallback',
                $taBase . 'assets/typeahead/seekmodo-typeahead.js',
                [],
                $taVer,
                true
            );
            wp_enqueue_script('seekmodo-typeahead-fallback');
            wp_add_inline_script(
                'seekmodo-typeahead-fallback',
                'window.SEEKMODO_TYPEAHEAD=' . wp_json_encode([
                    'endpoint'    => self::typeaheadFallbackUrl(),
                    'vertical'    => Bootstrap::wooCommerceActive() ? 'commerce' : 'content',
                    'brand'       => false,
                    'min_chars'   => 2,
                    'max_rows'    => 8,
                    'debounce_ms' => 150,
                    'selectors'   => ['input[data-seekmodo-suggest-fallback="1"]'],
                ]) . ';',
                'before'
            );
        }
    }

    /**
     * @return array<string,mixed>
     */
    private static function config(): array
    {
        $selectors = self::selectors();
        $blocks    = self::blocks();
        $viewAll   = self::viewAllHref();
        $extras    = self::extraAttrs();

        return [
            'selectors'      => $selectors,
            'blocks'         => $blocks,
            'view_all_href'  => $viewAll,
            'serp_passthrough' => self::serpPassthrough(),
            'extras'         => $extras,
            'layout'         => self::layout(),
            'show_branding'  => self::suggestShowBranding(),
            'typeahead_fallback_url' => self::typeaheadFallbackUrl(),
            'click_endpoint' => function_exists('rest_url')
                ? (string) rest_url(Routes::NAMESPACE_NAME . '/click')
                : '',
            'click_nonce'    => function_exists('wp_create_nonce') ? wp_create_nonce('wp_rest') : '',
        ];
    }

    private static function typeaheadFallbackUrl(): string
    {
        if (!function_exists('rest_url')) {
            return '';
        }

        return (string) rest_url(Routes::NAMESPACE_NAME . '/typeahead');
    }

    /**
     * Build the inline autoboot script that walks the selectors and
     * inserts `<seekmodo-suggest>` siblings. JSON-encoded config is
     * inlined; the script reads it from `window.SEEKMODO_SUGGEST_CFG`
     * to keep the per-page payload small.
     *
     * @param array<string,mixed> $config
     */
    private static function buildAutobootScript(array $config): string
    {
        $json = function_exists('wp_json_encode')
            ? (string) wp_json_encode($config)
            : (string) json_encode($config);
        if ($json === '' || $json === 'false') {
            return '';
        }

        // Inline IIFE — minimal, tolerant of multiple inputs, idempotent
        // on accidental re-runs (skips inputs that already have a
        // sibling `<seekmodo-suggest>`). Mirrors the legacy
        // typeahead autoboot's "attach once per matching input" posture.
        $tpl = <<<'JS'
(function () {
  var CFG = %CFG%;
  if (!CFG || !Array.isArray(CFG.selectors) || CFG.selectors.length === 0) return;
  var SELECTOR = CFG.selectors.join(',');
  function ensureId(input) {
    if (input.id && input.id.length > 0) return input.id;
    var rnd = 'seekmodo-suggest-input-' +
              Math.random().toString(36).slice(2, 10);
    input.id = rnd;
    return rnd;
  }
  function attach(input) {
    if (!input || input.dataset.seekmodoSuggest === '1') return;
    if (input.closest && input.closest('seekmodo-suggest')) return;
    var sib = input.nextElementSibling;
    if (sib && sib.tagName && sib.tagName.toLowerCase() === 'seekmodo-suggest' &&
        sib.getAttribute('input') === input.id) {
      input.dataset.seekmodoSuggest = '1';
      return;
    }
    var id = ensureId(input);
    var el = document.createElement('seekmodo-suggest');
    el.setAttribute('input', id);
    if (CFG.blocks) el.setAttribute('blocks', CFG.blocks);
    if (CFG.view_all_href) el.setAttribute('view-all-href', CFG.view_all_href);
    if (CFG.serp_passthrough && typeof CFG.serp_passthrough === 'object' &&
        Object.keys(CFG.serp_passthrough).length > 0) {
      el.setAttribute('serp-passthrough', JSON.stringify(CFG.serp_passthrough));
    }
    el.setAttribute('layout', CFG.layout || 'split-rail');
    el.setAttribute('show-branding', CFG.show_branding ? 'true' : 'false');
    if (CFG.extras && typeof CFG.extras === 'object') {
      for (var k in CFG.extras) {
        if (Object.prototype.hasOwnProperty.call(CFG.extras, k)) {
          var v = CFG.extras[k];
          if (v === null || v === undefined || v === '') continue;
          el.setAttribute(k, String(v));
        }
      }
    }
    if (CFG.typeahead_fallback_url) {
      el.setAttribute('typeahead-fallback-url', CFG.typeahead_fallback_url);
    }
    input.parentNode.insertBefore(el, input.nextSibling);
    input.dataset.seekmodoSuggest = '1';
    input.setAttribute('autocomplete', 'off');
    if (CFG.typeahead_fallback_url) {
      document.addEventListener('seekmodo-suggest:empty', function (ev) {
        var detail = (ev && ev.detail) || {};
        var q = String(detail.q || '');
        if (q.length < 2) return;
        var inputEl = detail.input;
        if (!inputEl || inputEl.dataset.seekmodoSuggestFallback === '1') return;
        inputEl.dataset.seekmodoSuggestFallback = '1';
        inputEl.setAttribute('data-seekmodo-typeahead', '1');
        inputEl.setAttribute('data-seekmodo-suggest-fallback', '1');
      });
    }
  }
  function scan(root) {
    try {
      var inputs = (root || document).querySelectorAll(SELECTOR);
      for (var i = 0; i < inputs.length; i++) attach(inputs[i]);
    } catch (_e) { /* malformed selector — bail silently */ }
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', function () { scan(document); });
  } else {
    scan(document);
  }
  // Lazy inputs (Woo mini-cart, AJAX-loaded headers) — observe once.
  if ('MutationObserver' in window) {
    var mo = new MutationObserver(function (list) {
      for (var i = 0; i < list.length; i++) {
        var m = list[i];
        for (var j = 0; j < m.addedNodes.length; j++) {
          var n = m.addedNodes[j];
          if (n && n.nodeType === 1) {
            if (n.matches && n.matches(SELECTOR)) attach(n);
            scan(n);
          }
        }
      }
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  }
  // ---- Row-click navigation.
  //
  // The <seekmodo-suggest> web component intentionally does NOT
  // navigate on row click; it just emits a seekmodo-suggest:row-click
  // CustomEvent (composed: true, bubbles to document) and lets the
  // host decide where to send the shopper. Without this listener the
  // dropdown felt completely inert -- clicks visually highlighted
  // the row, the input briefly stole focus back, then nothing
  // happened.
  //
  // Behaviour:
  //   - products / categories with row.url -> location.href = row.url
  //   - keyword-style blocks (recent, trending, keywords,
  //     did_you_mean) -> view-all-href with {q} replaced by the
  //     row's keyword text
  //   - products / categories WITHOUT row.url -> view-all-href with
  //     {q} replaced by row.name (defensive fallback)
  function mirrorSuggestClick(detail) {
    var ep = CFG.click_endpoint;
    if (!ep) return;
    var block = detail.block;
    if (block !== 'products') return;
    var objectId = parseInt(detail.id || (detail.row && detail.row.id) || '0', 10);
    if (!objectId) return;
    var payload = {
      keyword: String(detail.q || ''),
      object_id: objectId,
      position: parseInt(detail.position, 10) || 1,
      surface: 'typeahead'
    };
    var seid = parseInt(detail.search_event_id, 10);
    if (seid > 0) {
      payload.search_event_id = seid;
    }
    try {
      var body = JSON.stringify(payload);
      if (navigator.sendBeacon) {
        navigator.sendBeacon(ep, new Blob([body], { type: 'application/json' }));
        return;
      }
      fetch(ep, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-WP-Nonce': CFG.click_nonce || ''
        },
        body: body,
        keepalive: true,
        credentials: 'same-origin'
      }).catch(function () {});
    } catch (_e) {}
  }
  document.addEventListener('seekmodo-suggest:row-click', function (ev) {
    var detail = (ev && ev.detail) || {};
    mirrorSuggestClick(detail);
    var block = detail.block;
    var row = detail.row || {};
    var q = String(detail.q || '');
    if ((block === 'products' || block === 'categories')
        && row && typeof row.url === 'string' && row.url) {
      window.location.href = row.url;
      return;
    }
    var keyword = '';
    if (block === 'did_you_mean') {
      keyword = String(detail.value || (row && row.value) || q);
    } else if (block === 'recent' || block === 'trending' || block === 'keywords') {
      keyword = String((row && row.keyword) || detail.value || q);
    } else if (block === 'products' || block === 'categories') {
      keyword = String((row && (row.name || row.title)) || detail.value || q);
    } else {
      keyword = String(detail.value || q);
    }
    if (!keyword) return;
    var viewAll = (CFG && CFG.view_all_href) || '/?s={q}';
    window.location.href = viewAll.replace('{q}', encodeURIComponent(keyword));
  });
  document.addEventListener('seekmodo-suggest:cors-blocked', function (ev) {
    var detail = (ev && ev.detail) || {};
    var inputEl = detail.input;
    if (inputEl && typeof window.seekmodoShowCorsNotice === 'function') {
      window.seekmodoShowCorsNotice(inputEl, detail.message);
    }
  });
})();
JS;

        return str_replace('%CFG%', $json, $tpl);
    }

    private static function masterEnabled(): bool
    {
        $v = get_option(Options::OPT_TYPEAHEAD_UI_ENABLED, '0');
        if (is_bool($v)) {
            return $v;
        }

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

    /**
     * v0.6.0 — when set, the legacy {@see TypeaheadUI} runs instead of
     * the new suggest widget. The two surfaces are mutually exclusive
     * to avoid double-attaching dropdowns to the same input.
     */
    private static function legacyForced(): bool
    {
        $v = get_option(Options::OPT_SUGGEST_LEGACY, '0');
        if (is_bool($v)) {
            return $v;
        }

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

    private static function gatewayUrl(): string
    {
        $u = (string) Options::gatewayUrl();
        if ($u === '') {
            $u = defined('SEEKMODO_WP_GATEWAY_URL')
                ? (string) SEEKMODO_WP_GATEWAY_URL
                : 'https://mcp.seekmodo.com';
        }

        return rtrim($u, '/');
    }

    private static function refreshUrl(): string
    {
        if (!function_exists('rest_url')) {
            return '';
        }

        return (string) rest_url(Routes::NAMESPACE_NAME . '/browser-token');
    }

    private static function layout(): string
    {
        $layout = Options::suggestLayout();

        return (string) apply_filters('seekmodo_suggest_layout', $layout);
    }

    private static function suggestShowBranding(): bool
    {
        $stored = Options::suggestShowBranding();

        return (bool) apply_filters('seekmodo_suggest_show_branding', $stored);
    }

    /**
     * @return array<int,string>
     */
    private static function selectors(): array
    {
        $defaults = [
            'input[type="search"]',
            'input[name="s"]',
            'input[data-seekmodo-suggest]',
            'input[data-seekmodo-typeahead]',
        ];
        $custom = apply_filters('seekmodo_suggest_selectors', $defaults);
        if (!is_array($custom) || $custom === []) {
            return $defaults;
        }
        $clean = [];
        foreach ($custom as $sel) {
            if (is_string($sel) && $sel !== '') {
                $clean[] = $sel;
            }
        }

        return $clean === [] ? $defaults : $clean;
    }

    private static function blocks(): string
    {
        $default = 'recent,did_you_mean,keywords,trending,products,categories';
        $v       = apply_filters('seekmodo_suggest_blocks', $default);

        return is_string($v) && $v !== '' ? $v : $default;
    }

    private static function viewAllHref(): string
    {
        $woo = Bootstrap::wooCommerceActive();
        $default = $woo
            ? '/?s={q}&post_type=product'
            : '/?s={q}';
        $v = apply_filters('seekmodo_suggest_view_all_href', $default);

        return is_string($v) && $v !== '' ? $v : $default;
    }

    /**
     * Typesense tuning forwarded into /v1/suggest SerpPreview so the
     * dropdown matches connector SERP when a site overrides defaults.
     *
     * @return array<string, mixed>
     */
    private static function serpPassthrough(): array
    {
        $default = [];
        if (function_exists('get_woocommerce_currency')) {
            $code = (string) get_woocommerce_currency();
            if ($code !== '') {
                $default['shopper_context'] = ['currency' => strtoupper($code)];
            }
        }
        $v = apply_filters('seekmodo_suggest_serp_passthrough', $default);

        return is_array($v) ? $v : $default;
    }

    /**
     * @return array<string,string>
     */
    private static function extraAttrs(): array
    {
        $defaults = [
            'min-length'  => '2',
            'debounce-ms' => '150',
            'limit'       => '15',
            'cache-size'  => '32',
            'split-mobile-resize' => 'true',
            'product-title-tooltip' => 'true',
            // SM-606 follow-up: the bundle's `suppress-legacy` attribute
            // tears down sibling typeahead widgets bound to the same
            // input on first focus. We default to the legacy
            // `<seekmodo-typeahead>` Sprint-7 widget because many
            // WordPress sites still enqueue both during the soft cutover
            // (TypeaheadUI + SuggestUI are independent toggles in
            // wp-admin). Themes that want the previous behavior — both
            // widgets coexisting — can drop this entry via the
            // `seekmodo_suggest_extra_attrs` filter. Add `jquery-ui`
            // (CSV) for storefronts that also enqueue jQuery UI
            // autocomplete on the same input.
            'suppress-legacy' => 'seekmodo-typeahead',
        ];
        if (function_exists('get_woocommerce_currency')) {
            $code = (string) get_woocommerce_currency();
            if ($code !== '') {
                $defaults['currency'] = strtoupper($code);
            }
        }
        $custom = apply_filters('seekmodo_suggest_extra_attrs', $defaults);
        if (!is_array($custom)) {
            return $defaults;
        }
        $clean = [];
        foreach ($custom as $k => $v) {
            if (is_string($k) && $k !== '' && (is_string($v) || is_int($v) || is_float($v))) {
                $clean[$k] = (string) $v;
            }
        }

        return $clean === [] ? $defaults : $clean;
    }
}
