<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Content;

use Numinix\SeekmodoWordPress\Mode\AutoPromoter;
use Numinix\SeekmodoWordPress\Mode\ModeFsm;
use Numinix\SeekmodoWordPress\Mode\ShadowDiffLogger;
use Numinix\SeekmodoWordPress\Search\EnhancedNativeRegistry;

/**
 * WordPress core search swap.
 *
 * The swap fires only when ALL of the following are true:
 *
 *   - The query is a search query (is_search()).
 *   - The query is the main query (no overriding it on widget loops).
 *   - The query's post_type intersects PostTypeRegistry::indexable().
 *   - The query is NOT for a Woo product (post_type=product) — that's
 *     handled by Commerce/SearchSwap separately, on a different hook
 *     so the two verticals can't double-fire.
 *
 * Two hooks at two priorities:
 *
 *   - pre_get_posts (priority 9): clamps the query to our indexable
 *     post types and stamps `seekmodo_swap_active=true` on the query
 *     so other plugins / themes can detect it.
 *
 *   - posts_pre_query (priority 10): the actual swap. Calls the
 *     gateway via numinix_seekmodo_run_search(). On null return,
 *     returns null itself to let WP execute its native LIKE SQL. On
 *     success, returns a hydrated WP_Post[] in gateway-ranked order
 *     and short-circuits the SQL pass entirely.
 *
 * Shadow mode: the swap still calls the gateway but always returns
 * null from posts_pre_query so the visitor sees WP's native result.
 * The native_ids vs gateway_ids divergence is forwarded to
 * ShadowDiffLogger for the 7-day diff signal.
 *
 * Mode selection: we read `AutoPromoter::effectiveMode()` (not
 * `ModeFsm::current()`) so the AutoPromoter substate FSM actually
 * drives swap behavior. The mapping is:
 *
 *   MODE=off                       -> swap-points are disabled at
 *                                     the `numinix_seekmodo_enabled()`
 *                                     gate. We never get here.
 *   MODE=shadow (operator pin)     -> shadow branch (native results
 *                                     + diff log). AutoPromoter does
 *                                     not promote a manual shadow.
 *   MODE=active + sub=shadow       -> shadow branch (Auto mode bake-in).
 *   MODE=active + sub=enforce      -> enforce branch (gateway results).
 *   MODE=active + auto_promote=0   -> enforce branch (operator opted
 *                                     out of the safety hop).
 *   MODE=enforce (operator pin)    -> enforce branch.
 */
final class SearchSwap
{
    public static function register(): void
    {
        if (!function_exists('add_action')) {
            return;
        }
        add_action('pre_get_posts', [self::class, 'preGetPosts'], 9);
        add_filter('posts_pre_query', [self::class, 'postsPreQuery'], 10, 2);
    }

    /**
     * @param mixed $query A WP_Query.
     */
    public static function preGetPosts($query): void
    {
        if (!self::isSwappable($query)) {
            return;
        }
        // Mark the query so posts_pre_query knows we own it. Some themes also
        // read this attribute to skip their own re-runs.
        if (is_object($query) && method_exists($query, 'set')) {
            $query->set('seekmodo_swap_active', true);
        }
    }

    /**
     * @param mixed $posts Either null (default) or an array of WP_Posts already populated by a prior filter.
     * @param mixed $query A WP_Query.
     * @return mixed
     */
    public static function postsPreQuery($posts, $query)
    {
        if ($posts !== null) {
            return $posts;
        }
        if (!self::isSwappable($query)) {
            return null;
        }

        $keyword   = is_object($query) && method_exists($query, 'get') ? trim((string) $query->get('s')) : '';
        $page      = self::pagedFromQuery($query);
        $page_size = self::pageSizeFromQuery($query);
        if ($keyword === '') {
            return null;
        }

        $params = [
            'q'           => $keyword,
            'page'        => $page,
            'page_size'   => $page_size,
            'lang'        => function_exists('get_locale') ? get_locale() : 'en_US',
            'vertical'    => 'content',
            'post_types'  => PostTypeRegistry::indexable(),
            'filters'     => [],
        ];

        $started_at = microtime(true);
        $resp       = null;
        if (function_exists('numinix_seekmodo_gateway_enabled') && numinix_seekmodo_gateway_enabled()) {
            $resp = numinix_seekmodo_run_search($params);
        }
        $latency_ms = (int) round((microtime(true) - $started_at) * 1000);

        // Consult the *effective* mode rather than the headline mode
        // so the AutoPromoter substate FSM actually drives behavior.
        // When MODE=active with substate=shadow (the "Auto mode"
        // default for a freshly-activated tenant), this returns
        // `shadow` so we still emit the diff but show native results
        // — exactly until the AutoPromoter flips substate to enforce.
        $mode = AutoPromoter::effectiveMode();

        if ($mode === ModeFsm::MODE_SHADOW) {
            if (is_array($resp) && isset($resp['ids']) && is_array($resp['ids'])) {
                $native_ids = self::peekNativeIdsForDiff($query);
                ShadowDiffLogger::log($keyword, $native_ids, array_values(array_map('intval', $resp['ids'])), $latency_ms);
            }

            return self::enhancedNativePosts($query, $keyword, $page, $page_size);
        }

        if (!is_array($resp) || !isset($resp['ids']) || !is_array($resp['ids'])) {
            return self::enhancedNativePosts($query, $keyword, $page, $page_size);
        }

        $ids = array_values(array_filter(array_map('intval', $resp['ids'])));
        if (empty($ids)) {
            $enhanced = EnhancedNativeRegistry::runContentSearch($keyword, $page, $page_size);
            if ($enhanced !== null && $enhanced['ids'] !== []) {
                return self::hydrateFromIds($query, $enhanced['ids'], $page_size, $enhanced['total']);
            }

            return [];
        }

        EnhancedNativeRegistry::recordGatewayIds($keyword, $ids);

        // Editorial re-rank by post-type priority. No-op when the
        // operator hasn't set OPT_POST_TYPE_PRIORITY (and no filter
        // adds one), so the default v0.3.3-and-earlier behavior is
        // preserved -- gateway score order flows through untouched.
        // Within each priority tier the gateway's relative order is
        // preserved; we only bucket-sort by post type. See the
        // PostTypePriority class header for the full contract.
        $ids = PostTypePriority::applyOrder($ids);

        // Hydrate in (possibly-rewritten) gateway-ranked order. We
        // still rely on `orderby=post__in` so the bucket-sorted ID
        // sequence is what reaches the storefront's loop.
        $hydrated = self::hydrateInOrder($ids);

        // Stamp the total + page count on the query so the storefront's
        // pagination + result-count UI still works.
        if (is_object($query) && method_exists($query, 'set')) {
            $total = isset($resp['total']) ? (int) $resp['total'] : count($hydrated);
            $query->found_posts   = $total;
            $query->max_num_pages = $page_size > 0 ? (int) ceil($total / $page_size) : 1;
        }

        return $hydrated;
    }

    /**
     * @param mixed $query
     */
    private static function isSwappable($query): bool
    {
        if (!is_object($query)) {
            return false;
        }
        if (!method_exists($query, 'is_search') || !method_exists($query, 'is_main_query')) {
            return false;
        }
        if (!$query->is_search()) {
            return false;
        }
        if (!$query->is_main_query()) {
            return false;
        }
        if (is_admin()) {
            return false;
        }
        if (!function_exists('numinix_seekmodo_enhanced_native_enabled')
            || !numinix_seekmodo_enhanced_native_enabled()) {
            if (!function_exists('numinix_seekmodo_gateway_enabled') || !numinix_seekmodo_gateway_enabled()) {
                return false;
            }
        }

        $post_type = method_exists($query, 'get') ? $query->get('post_type') : '';
        if ($post_type === 'product' || $post_type === 'product_variation') {
            // Commerce/SearchSwap owns Woo product queries.
            return false;
        }
        $indexable = PostTypeRegistry::indexable();
        if (empty($indexable)) {
            return false;
        }

        return true;
    }

    /**
     * @param mixed $query
     */
    private static function pagedFromQuery($query): int
    {
        if (!is_object($query) || !method_exists($query, 'get')) {
            return 1;
        }
        $paged = (int) $query->get('paged');

        return $paged > 0 ? $paged : 1;
    }

    /**
     * @param mixed $query
     */
    private static function pageSizeFromQuery($query): int
    {
        if (!is_object($query) || !method_exists($query, 'get')) {
            return 10;
        }
        $size = (int) $query->get('posts_per_page');
        if ($size <= 0) {
            $size = (int) (function_exists('get_option') ? get_option('posts_per_page', 10) : 10);
        }

        return $size > 0 ? min($size, 100) : 10;
    }

    /**
     * @param array<int,int> $ids
     * @return array<int,object>
     */
    private static function hydrateInOrder(array $ids): array
    {
        if (empty($ids) || !function_exists('get_posts')) {
            return [];
        }
        // Single query pulls every post; we then re-order in PHP.
        $posts = get_posts([
            'post__in'            => $ids,
            'orderby'             => 'post__in',
            'posts_per_page'      => count($ids),
            'post_type'           => 'any',
            'post_status'         => 'publish',
            'suppress_filters'    => false,
            'no_found_rows'       => true,
        ]);
        if (!is_array($posts)) {
            return [];
        }
        $by_id = [];
        foreach ($posts as $p) {
            $by_id[(int) ($p->ID ?? 0)] = $p;
        }
        $out = [];
        foreach ($ids as $id) {
            if (isset($by_id[$id])) {
                $out[] = $by_id[$id];
            }
        }

        return $out;
    }

    /**
     * @param mixed $query
     * @return array<int,object>|null
     */
    private static function enhancedNativePosts($query, string $keyword, int $page, int $page_size): ?array
    {
        $enhanced = EnhancedNativeRegistry::runContentSearch($keyword, $page, $page_size);
        if ($enhanced === null) {
            return null;
        }

        return self::hydrateFromIds($query, $enhanced['ids'], $page_size, $enhanced['total']);
    }

    /**
     * @param list<int> $ids
     * @return array<int,object>
     */
    private static function hydrateFromIds($query, array $ids, int $page_size, int $total): array
    {
        $ids = PostTypePriority::applyOrder($ids);
        $hydrated = self::hydrateInOrder($ids);
        if (is_object($query) && method_exists($query, 'set')) {
            $query->found_posts   = $total;
            $query->max_num_pages = $page_size > 0 ? (int) ceil($total / $page_size) : 1;
        }

        return $hydrated;
    }

    /**
     * Best-effort native-ID peek for shadow-mode diff logging.
     * Performs a cheap SELECT and discards. We don't want to influence
     * the native result the visitor sees, so we work off a clone of
     * the query with `fields=ids` set.
     *
     * @param mixed $query
     * @return array<int,int>
     */
    private static function peekNativeIdsForDiff($query): array
    {
        if (!class_exists('WP_Query', false) || !is_object($query) || !method_exists($query, 'query_vars')) {
            return [];
        }
        try {
            $vars            = method_exists($query, 'query_vars') ? $query->query_vars : [];
            $vars['fields']  = 'ids';
            $vars['suppress_filters'] = true; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- short-circuit native query after Seekmodo results
            $clone           = new \WP_Query($vars);
            $ids             = isset($clone->posts) && is_array($clone->posts) ? $clone->posts : [];

            return array_values(array_map('intval', $ids));
        } catch (\Throwable $e) {
            return [];
        }
    }
}
