/*!
 * Seekmodo Search — typeahead dropdown UI for WordPress.
 *
 * Vanilla JS (no jQuery, no React) drop-in that auto-attaches to the
 * theme's search inputs and renders a Seekmodo-branded results
 * dropdown beneath each one. Mirrors the Zen Cart connector's
 * `jscript_seekmodo_typeahead.js` so the cross-platform UX is
 * consistent for an operator running both connectors on a hybrid
 * publisher / Woo storefront.
 *
 * Surfaces it talks to:
 *
 *   - GET `/wp-json/seekmodo/v1/typeahead?q=...&max=...&vertical=...`
 *     (registered by `Content/Typeahead.php`; the `commerce` vertical
 *     swaps to product-card row shapes via the
 *     `seekmodo_typeahead_shape_row` filter in `Commerce/Typeahead.php`).
 *
 *   - The bundled click beacon at `assets/typeahead/seekmodo-beacon.js`
 *     picks up `data-seekmodo-id` / `data-seekmodo-pos` /
 *     `data-seekmodo-surface="typeahead"` attributes that this script
 *     stamps on every dropdown row, so click-attribution flows
 *     through the existing pipeline without a second beacon.
 *
 * Boot contract (set by `Frontend/TypeaheadUI.php` via
 * `wp_localize_script`):
 *
 *   window.SEEKMODO_TYPEAHEAD = {
 *     endpoint:    'https://example.com/wp-json/seekmodo/v1/typeahead',
 *     vertical:    'content' | 'commerce',
 *     brand:       false | true,         // show the "Powered by" footer
 *     placeholder: 'Search…',            // forwarded to inputs missing one
 *     min_chars:   2,
 *     max_rows:    8,
 *     debounce_ms: 150,
 *     selectors:   ['input[type="search"]', 'input[name="s"]', 'input[data-seekmodo-typeahead]'],
 *     i18n: {
 *       no_results:    'No results',
 *       powered_by:    'Powered by',
 *       brand:         'Seekmodo',
 *       on_sale:       'Sale',
 *       out_of_stock:  'Out of stock',
 *       backorder:     'On backorder',
 *     },
 *   };
 *
 * Themes with their own typeahead implementation can suppress this
 * UI either by:
 *   - returning `false` from the PHP `seekmodo_enqueue_typeahead`
 *     filter (preferred; bypasses enqueue entirely), OR
 *   - flipping the per-site "Enable Seekmodo typeahead" toggle off
 *     in wp-admin Settings → Seekmodo, OR
 *   - decorating their custom search input with
 *     `data-seekmodo-typeahead-skip` so this script ignores it.
 *
 * Trek.ca's theme integration uses option (B) — its custom
 * `inc/seekmodo-search.php` keeps the connector's typeahead UI off
 * site-wide and renders a bespoke header popover instead. POCO.com
 * relies on option (A) being on with `brand=false` for a white-label
 * experience.
 */
(function () {
    'use strict';

    if (typeof window === 'undefined' || typeof document === 'undefined') {
        return;
    }
    if (typeof window.fetch !== 'function') {
        // IE11 / very old Safari — bail silently. The theme's existing
        // search submission still works; only the dropdown is skipped.
        return;
    }

    var CFG = window.SEEKMODO_TYPEAHEAD || {};
    if (!CFG.endpoint) {
        return;
    }

    var DEBOUNCE_MS = (typeof CFG.debounce_ms === 'number' && CFG.debounce_ms > 0)
        ? CFG.debounce_ms : 150;
    var MIN_CHARS = (typeof CFG.min_chars === 'number' && CFG.min_chars > 0)
        ? CFG.min_chars : 2;
    var MAX_ROWS = (typeof CFG.max_rows === 'number' && CFG.max_rows > 0)
        ? CFG.max_rows : 8;
    var VERTICAL = (CFG.vertical === 'commerce') ? 'commerce' : 'content';
    var BRAND = !!CFG.brand;
    var CACHE_SIZE = (typeof CFG.cache_size === 'number' && CFG.cache_size > 0)
        ? Math.min(128, CFG.cache_size | 0) : 32;
    // SM-602 v0.5.0 — gateway-direct config (token + URL + tenant).
    // Present iff the connector minted a browser token at page render.
    // Absent for unpaired sites, sites whose plan disables the
    // mint, or anything else that fell open at server-side enqueue
    // time. Empty / partially-populated objects are treated as absent.
    var GW = (CFG.gateway && typeof CFG.gateway === 'object'
        && typeof CFG.gateway.url === 'string' && CFG.gateway.url
        && typeof CFG.gateway.tenant === 'string' && CFG.gateway.tenant
        && typeof CFG.gateway.token === 'string' && CFG.gateway.token)
        ? CFG.gateway : null;
    // Token-expiry-as-absolute-epoch-seconds. The mint comes back with
    // ttl=300 and we re-mint server-side when we get within 60s of the
    // floor; this bound is a defensive floor in the JS for a user who
    // sat on a tab past the cached transient's TTL. When we trip it
    // we drop to the WP REST endpoint for the next keystroke instead
    // of POSTing an expired JWT (which would 401 + fall back anyway,
    // just with an extra round trip).
    var GW_EXPIRES_AT = (GW && typeof GW.expires_at === 'number') ? GW.expires_at : 0;

    var I18N = (function () {
        var d = {
            no_results:   'No results',
            powered_by:   'Powered by',
            brand:        'Seekmodo',
            on_sale:      'Sale',
            out_of_stock: 'Out of stock',
            backorder:    'On backorder'
        };
        if (CFG.i18n && typeof CFG.i18n === 'object') {
            for (var k in CFG.i18n) {
                if (Object.prototype.hasOwnProperty.call(CFG.i18n, k) && typeof CFG.i18n[k] === 'string') {
                    d[k] = CFG.i18n[k];
                }
            }
        }
        return d;
    })();

    var DEFAULT_SELECTORS = [
        'input[type="search"]',
        'input[name="s"]',
        'input[data-seekmodo-typeahead]'
    ];
    var SELECTORS = (Array.isArray(CFG.selectors) && CFG.selectors.length > 0)
        ? CFG.selectors
        : DEFAULT_SELECTORS;

    var ROOT_CLASS = 'seekmodo-typeahead';
    var DROPDOWN_CLASS = ROOT_CLASS + '-dropdown';
    var ROW_CLASS = ROOT_CLASS + '-row';

    function ready(fn) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', fn, { once: true });
        } else {
            fn();
        }
    }

    function escapeHtml(s) {
        return String(s == null ? '' : s)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    function formatPrice(value, currency) {
        if (value == null || value === '' || isNaN(parseFloat(value))) {
            return '';
        }
        var num = parseFloat(value);
        var symbol = '';
        var cur = (currency || '').toUpperCase();
        if (cur === 'USD' || cur === 'CAD' || cur === 'AUD' || cur === 'NZD') {
            symbol = '$';
        } else if (cur === 'EUR') {
            symbol = '€';
        } else if (cur === 'GBP') {
            symbol = '£';
        } else if (cur === 'JPY') {
            symbol = '¥';
        }
        // Two-decimal default; JPY is whole-yen.
        var fixed = (cur === 'JPY') ? num.toFixed(0) : num.toFixed(2);
        return symbol + fixed + (symbol ? '' : (cur ? (' ' + cur) : ''));
    }

    function selectInputs() {
        var seen = new Set();
        var out = [];
        for (var i = 0; i < SELECTORS.length; i++) {
            var nodes;
            try {
                nodes = document.querySelectorAll(SELECTORS[i]);
            } catch (e) {
                continue;
            }
            for (var j = 0; j < nodes.length; j++) {
                var el = nodes[j];
                if (!el || el.tagName !== 'INPUT') continue;
                if (el.type === 'hidden') continue;
                if (el.hasAttribute('data-seekmodo-typeahead-skip')) continue;
                if (seen.has(el)) continue;
                seen.add(el);
                out.push(el);
            }
        }
        return out;
    }

    function buildDropdown(input) {
        var dd = document.createElement('div');
        dd.className = DROPDOWN_CLASS;
        dd.setAttribute('role', 'listbox');
        dd.setAttribute('aria-label', I18N.brand);
        dd.hidden = true;
        document.body.appendChild(dd);

        function position() {
            var rect = input.getBoundingClientRect();
            dd.style.top   = (rect.bottom + window.pageYOffset) + 'px';
            dd.style.left  = (rect.left + window.pageXOffset) + 'px';
            dd.style.width = rect.width + 'px';
        }
        return { el: dd, position: position };
    }

    function renderEmpty(dd, q) {
        dd.el.innerHTML = '<div class="' + ROOT_CLASS + '-empty">'
            + escapeHtml(I18N.no_results) + ' "<span>' + escapeHtml(q) + '</span>"</div>'
            + footerHtml();
        dd.el.hidden = false;
    }

    function footerHtml() {
        if (!BRAND) {
            return '';
        }
        return '<div class="' + ROOT_CLASS + '-footer">'
            +  '<span class="' + ROOT_CLASS + '-footer-text">' + escapeHtml(I18N.powered_by) + '</span> '
            +  '<span class="' + ROOT_CLASS + '-footer-brand">' + escapeHtml(I18N.brand) + '</span>'
            + '</div>';
    }

    function renderContentRows(rows) {
        var html = '';
        for (var i = 0; i < rows.length; i++) {
            var r = rows[i];
            // Accept both `title` (canonical content shape per
            // CONNECTOR_TYPEAHEAD_SPEC.md) and `name` (defensive —
            // matches commerce naming if a content tenant ever
            // remaps fields server-side).
            var title = (r && (r.title || r.name)) ? String(r.title || r.name) : '';
            if (!title) continue;
            var url       = r.permalink || r.url || '#';
            var thumb     = r.thumbnail_url || r.image_url || r.image || '';
            var pt        = (r.post_type || '').toLowerCase();
            var excerpt   = r.excerpt || '';
            var oid       = parseInt(r.id || 0, 10) || 0;
            html += '<a class="' + ROW_CLASS + ' ' + ROW_CLASS + '--content"'
                +  ' href="' + escapeHtml(url) + '"'
                +  ' data-seekmodo-id="' + oid + '"'
                +  ' data-seekmodo-pos="' + (i + 1) + '"'
                +  ' data-seekmodo-surface="typeahead"'
                +  ' role="option"'
                +  '>';
            if (thumb) {
                html += '<span class="' + ROW_CLASS + '-thumb">'
                    +    '<img src="' + escapeHtml(thumb) + '" alt="" loading="lazy" />'
                    +  '</span>';
            }
            html += '<span class="' + ROW_CLASS + '-body">'
                +    '<span class="' + ROW_CLASS + '-title">' + escapeHtml(title) + '</span>';
            if (pt && pt !== 'post') {
                html += '<span class="' + ROW_CLASS + '-meta">' + escapeHtml(pt) + '</span>';
            }
            if (excerpt) {
                html += '<span class="' + ROW_CLASS + '-excerpt">' + escapeHtml(excerpt) + '</span>';
            }
            html += '</span></a>';
        }
        return html;
    }

    function renderCommerceRows(rows) {
        var html = '';
        for (var i = 0; i < rows.length; i++) {
            var r = rows[i];
            // SM-602 / PM-303: dual-shape support. The canonical
            // commerce row from `/v1/typeahead` (gateway-direct) per
            // CONNECTOR_TYPEAHEAD_SPEC.md uses `name` / `url` /
            // `image` / `in_stock`. The WP REST proxy endpoint
            // (`/wp-json/seekmodo/v1/typeahead`) currently rewrites
            // those to `title` / `permalink` / `image_url` /
            // `stock_status` for legacy reasons — so when v0.5.0+
            // started fetching gateway-direct from the browser,
            // every `r.title` lookup hit `undefined` and the
            // dropdown rendered an empty "No results" state even
            // though the wire returned 8 rows. Accept both names
            // here so either fetcher path renders identically.
            var title = (r && (r.name || r.title)) ? String(r.name || r.title) : '';
            if (!title) continue;
            var url    = r.url || r.permalink || '#';
            var image  = r.image_url || r.image || r.thumbnail_url || '';
            var oid    = parseInt(r.id || 0, 10) || 0;
            var price  = formatPrice(r.price, r.currency);
            var sale   = r.on_sale ? formatPrice(r.sale_price, r.currency) : '';
            // Normalize stock state across both shapes:
            //   - canonical:  in_stock: true | false
            //   - legacy:     stock_status: "instock" | "outofstock" | "onbackorder"
            var stock = r.stock_status;
            if (!stock && typeof r.in_stock === 'boolean') {
                stock = r.in_stock ? 'instock' : 'outofstock';
            }
            stock = stock || 'instock';

            html += '<a class="' + ROW_CLASS + ' ' + ROW_CLASS + '--commerce"'
                +  ' href="' + escapeHtml(url) + '"'
                +  ' data-seekmodo-id="' + oid + '"'
                +  ' data-seekmodo-pos="' + (i + 1) + '"'
                +  ' data-seekmodo-surface="typeahead"'
                +  ' role="option"'
                +  '>';
            if (image) {
                html += '<span class="' + ROW_CLASS + '-thumb">'
                    +    '<img src="' + escapeHtml(image) + '" alt="" loading="lazy" />'
                    +  '</span>';
            }
            html += '<span class="' + ROW_CLASS + '-body">'
                +    '<span class="' + ROW_CLASS + '-title">' + escapeHtml(title) + '</span>'
                +    '<span class="' + ROW_CLASS + '-price">';
            if (sale) {
                html += '<span class="' + ROW_CLASS + '-price-now">' + escapeHtml(sale) + '</span>'
                    +   '<span class="' + ROW_CLASS + '-price-was">' + escapeHtml(price) + '</span>'
                    +   '<span class="' + ROW_CLASS + '-badge ' + ROW_CLASS + '-badge--sale">' + escapeHtml(I18N.on_sale) + '</span>';
            } else if (price) {
                html += '<span class="' + ROW_CLASS + '-price-now">' + escapeHtml(price) + '</span>';
            }
            if (stock === 'outofstock') {
                html += '<span class="' + ROW_CLASS + '-badge ' + ROW_CLASS + '-badge--oos">' + escapeHtml(I18N.out_of_stock) + '</span>';
            } else if (stock === 'onbackorder') {
                html += '<span class="' + ROW_CLASS + '-badge ' + ROW_CLASS + '-badge--bo">' + escapeHtml(I18N.backorder) + '</span>';
            }
            html += '</span></span></a>';
        }
        return html;
    }

    function render(dd, q, payload) {
        var rows = (payload && Array.isArray(payload.rows)) ? payload.rows : [];
        if (rows.length === 0) {
            renderEmpty(dd, q);
            return;
        }
        var body = (VERTICAL === 'commerce')
            ? renderCommerceRows(rows)
            : renderContentRows(rows);
        if (!body) {
            renderEmpty(dd, q);
            return;
        }
        dd.el.innerHTML = '<div class="' + ROOT_CLASS + '-rows" role="listbox">' + body + '</div>' + footerHtml();
        dd.el.hidden = false;
    }

    function debounce(fn, wait) {
        var t = null;
        return function () {
            var args = arguments;
            var self = this;
            if (t) clearTimeout(t);
            t = setTimeout(function () {
                t = null;
                fn.apply(self, args);
            }, wait);
        };
    }

    /**
     * Tiny LRU keyed on the normalized (vertical, max, q) tuple.
     * Backspace -> retype on an already-fetched prefix renders
     * instantly off the cache without a network round trip. Sized
     * by CACHE_SIZE (default 32 entries) — covers a typical session
     * without bloating the long-lived JS heap.
     *
     * Cache values are the parsed `{rows: [...], meta: {...}}`
     * payloads the gateway / WP REST endpoint returned, so a hit
     * skips both the network AND re-rendering from raw rows.
     */
    function lruCreate() {
        var map = new Map();
        return {
            get: function (k) {
                if (!map.has(k)) return null;
                var v = map.get(k);
                map.delete(k);
                map.set(k, v);
                return v;
            },
            set: function (k, v) {
                if (map.has(k)) map.delete(k);
                map.set(k, v);
                if (map.size > CACHE_SIZE) {
                    var oldest = map.keys().next().value;
                    map.delete(oldest);
                }
            }
        };
    }

    var CACHE = (typeof Map === 'function') ? lruCreate() : null;

    function cacheKey(q) {
        // Normalize the prefix to lowercase + collapsed whitespace
        // so "Boa  Engine" / "boa engine" / "BOA ENGINE" share the
        // same cache slot. Server-side TypeaheadTool does the same
        // normalization before its APCu/Cloudflare lookup, so the
        // client + server cache scopes align.
        var n = String(q || '').toLowerCase().replace(/\s+/g, ' ').trim();
        return VERTICAL + '|' + MAX_ROWS + '|' + n;
    }

    /**
     * Whether to use the gateway-direct fast path on this keystroke.
     * Returns false when:
     *   - The page didn't render with a token (tenant unpaired,
     *     mint was unavailable server-side).
     *   - The token is within 5s of expiry (let the next page load
     *     pick up a fresh one).
     */
    function canUseGateway() {
        if (!GW) return false;
        if (GW_EXPIRES_AT > 0 && (GW_EXPIRES_AT - 5) < Math.floor(Date.now() / 1000)) {
            // Stale — drop back to WP REST. Don't re-mint here:
            // minting needs HMAC-grade auth and only works server-
            // side; the WP REST endpoint will re-mint a token in
            // the same WP boot it would pay anyway.
            return false;
        }
        return true;
    }

    /**
     * Fetch from the gateway directly, skipping WP REST. Resolves
     * with the parsed payload on a 2xx, or null on any 4xx/5xx /
     * network error so the caller falls back to WP REST.
     *
     * @param {string} q     User-typed prefix
     * @param {AbortSignal=} signal
     */
    function gatewayFetch(q, signal) {
        var url = GW.url.replace(/\/$/, '') + '/v1/typeahead';
        var body = JSON.stringify({
            q: q,
            max: MAX_ROWS,
            // The server-side TypeaheadTool ignores the request
            // vertical and uses the tenant's vertical (single
            // source of truth for collection schema), so we send
            // it as a hint only — a tenant is paired to one
            // vertical at a time.
            vertical: VERTICAL,
            session_id: GW.session_id || ''
        });
        var headers = {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'Authorization': 'Bearer ' + GW.token,
            'X-Seekmodo-Tenant': GW.tenant
        };
        if (GW.platform) headers['X-Seekmodo-Connector-Platform'] = GW.platform;
        if (GW.version)  headers['X-Seekmodo-Connector-Version']  = GW.version;
        // Storefront-host header lets the gateway emit per-host
        // observability the same way HMAC traffic does. Browser
        // origin == storefront host on a paired site.
        try {
            headers['X-Seekmodo-Storefront-Host'] = window.location.host;
        } catch (_e) { /* in some sandboxes window.location is locked */ }

        return window.fetch(url, {
            method: 'POST',
            mode: 'cors',
            credentials: 'omit',
            signal: signal,
            headers: headers,
            body: body
        }).then(function (resp) {
            if (!resp.ok) return null;
            return resp.json();
        }).catch(function () {
            return null;
        });
    }

    /**
     * Fall-back WP REST fetch (the v0.4.0 path). Same shape; uses
     * GET to the WordPress endpoint that proxies through to the
     * gateway server-side. Slower because every request boots
     * WordPress, but always correct (works without a browser token,
     * survives a token expiry, isolated from CORS / breaker /
     * gateway-anonymous-zone configuration drift).
     */
    function restFetch(q, signal) {
        var sep = (CFG.endpoint.indexOf('?') >= 0) ? '&' : '?';
        var url = CFG.endpoint + sep
            + 'q=' + encodeURIComponent(q)
            + '&max=' + MAX_ROWS
            + '&vertical=' + encodeURIComponent(VERTICAL);
        return window.fetch(url, {
            method: 'GET',
            credentials: 'same-origin',
            signal: signal,
            headers: { 'Accept': 'application/json' }
        }).then(function (resp) {
            return resp.ok ? resp.json() : null;
        }).catch(function () {
            return null;
        });
    }

    function attach(input) {
        if (!input || input.dataset.seekmodoTypeaheadBound === '1') {
            return;
        }
        input.dataset.seekmodoTypeaheadBound = '1';
        input.setAttribute('autocomplete', 'off');

        var dd = buildDropdown(input);
        var inflight = null;
        var lastQ = '';

        function hide() {
            dd.el.hidden = true;
        }
        function show() {
            dd.position();
            dd.el.hidden = false;
        }

        function run(q) {
            // 1. Cache hit -> render synchronously, no network.
            if (CACHE) {
                var cached = CACHE.get(cacheKey(q));
                if (cached) {
                    dd.position();
                    render(dd, q, cached);
                    return;
                }
            }

            // 2. Otherwise issue a fresh request. Abort any
            //    in-flight one so a fast typist's "boat" -> "boats"
            //    doesn't render stale "boat" rows after "boats"
            //    arrives.
            if (inflight && typeof inflight.abort === 'function') {
                inflight.abort();
            }
            var ctrl = (typeof AbortController === 'function') ? new AbortController() : null;
            inflight = ctrl;
            var signal = ctrl ? ctrl.signal : undefined;

            // 3. Pick the fastest available fetcher. Gateway-direct
            //    skips WordPress entirely (~1.5s/keystroke saved on
            //    a heavy WP install). Falls through to the WP REST
            //    endpoint on a 401 (token expired between page
            //    render + this keystroke) or any other failure
            //    mode.
            var primary;
            var allowFallback;
            if (canUseGateway()) {
                primary = gatewayFetch(q, signal);
                allowFallback = true;
            } else {
                primary = restFetch(q, signal);
                allowFallback = false;
            }

            primary.then(function (payload) {
                if (payload) return payload;
                if (!allowFallback) return null;
                // Gateway-direct returned non-2xx (most likely 401
                // because the page-load token expired). Re-issue
                // through WP REST. We don't bump the abort
                // controller — same logical request, second-best
                // path.
                return restFetch(q, signal);
            }).then(function (payload) {
                if (q !== lastQ) {
                    return;
                }
                if (!payload) {
                    if (allowFallback && typeof window.seekmodoShowCorsNotice === 'function') {
                        window.seekmodoShowCorsNotice(input);
                    }
                    hide();
                    return;
                }
                if (CACHE) {
                    CACHE.set(cacheKey(q), payload);
                }
                dd.position();
                render(dd, q, payload);
            }).catch(function () {
                hide();
            });
        }

        var debounced = debounce(function () {
            var q = (input.value || '').trim();
            lastQ = q;
            if (q.length < MIN_CHARS) {
                hide();
                return;
            }
            run(q);
        }, DEBOUNCE_MS);

        input.addEventListener('input', debounced);
        input.addEventListener('focus', function () {
            var q = (input.value || '').trim();
            if (q.length >= MIN_CHARS) {
                debounced();
            }
        });
        input.addEventListener('blur', function () {
            // Defer hide so a click on a row registers before
            // the dropdown disappears.
            setTimeout(hide, 150);
        });
        input.addEventListener('keydown', function (e) {
            if (e.key === 'Escape') {
                hide();
            }
        });
        window.addEventListener('resize', dd.position);
        window.addEventListener('scroll', dd.position, true);

        // The dropdown element is appended to <body>; clicks on a
        // row are <a> tags so the browser handles navigation. The
        // beacon hooked at document level via seekmodo-beacon.js
        // intercepts on the way out.
    }

    ready(function () {
        var inputs = selectInputs();
        for (var i = 0; i < inputs.length; i++) {
            attach(inputs[i]);
        }
        // Re-scan on DOM mutations so inputs added by client-side
        // routers (e.g. headless Gutenberg pages, AJAX cart drawers
        // that re-render a search input) get attached too.
        if (typeof MutationObserver === 'function') {
            var obs = new MutationObserver(function () {
                var fresh = selectInputs();
                for (var i = 0; i < fresh.length; i++) {
                    attach(fresh[i]);
                }
            });
            obs.observe(document.body, { childList: true, subtree: true });
        }
    });
})();
