<?php

declare(strict_types=1);

namespace Numinix\SeekmodoWordPress\Content;

use Numinix\SeekmodoWordPress\Config\Options;

/**
 * Post-type priority re-ranker for content search results.
 *
 * The gateway ranks search hits by Typesense relevance score, which
 * is great for "best textual match" but agnostic to a site's
 * editorial intent. trek.ca surfaced the canonical example:
 * searching for "PPC" returned a Toronto-PPC-Agency page (great)
 * mixed in with eight blog posts and the actual /services/ pages
 * buried below. Operationally the merchant wants pages -- which
 * include the /services/ hierarchy -- to outrank the blog feed,
 * even when a blog post happens to have a slightly higher textual
 * relevance score.
 *
 * This class provides a configurable post-type priority order. When
 * set, gateway-returned IDs are re-sorted client-side so that:
 *
 *   1. IDs whose post_type appears earlier in the priority list
 *      come first.
 *   2. IDs whose post_type appears later in the list come after.
 *   3. IDs whose post_type is not in the list at all come LAST,
 *      preserving the gateway's relative order among them.
 *
 * Within each tier the gateway's ranking is preserved -- we don't
 * re-score textual relevance, we just bucket-sort by post type.
 * That keeps the "best PPC blog post" near the top of the post tier
 * once the page tier is exhausted.
 *
 * Configuration:
 *
 *   1. Operator option `seekmodo_wp_post_type_priority` (string,
 *      comma-separated, e.g. "page,post,services"). Easiest to set
 *      from the admin Settings page or `wp option update`.
 *   2. Filter `seekmodo_post_type_priority` (array<string>). Useful
 *      for site-specific mu-plugins that want to compute priority
 *      from URL prefix or category instead of post-type.
 *
 * If the option is empty AND the filter doesn't add anything, the
 * re-rank is a no-op -- the gateway's order flows through untouched.
 *
 * Cost: one indexed SQL `IN (...)` against `wp_posts.ID -> post_type`
 * per search. The same posts are about to be hydrated by SearchSwap
 * in the very next step anyway, so the marginal cost is negligible
 * for typical page sizes (<= 100 IDs).
 */
final class PostTypePriority
{
    /**
     * Re-order $ids by post-type priority, preserving the gateway's
     * relative order within each tier.
     *
     * @param array<int,int> $ids Gateway-ranked post IDs.
     * @return array<int,int>
     */
    public static function applyOrder(array $ids): array
    {
        if (count($ids) <= 1) {
            return $ids;
        }
        $priority = self::priority();
        if (empty($priority)) {
            return $ids;
        }

        $types = self::lookupTypes($ids);
        if (empty($types)) {
            // Could not resolve post types (e.g. wpdb unavailable in
            // a test harness) -- pass through unchanged rather than
            // silently dropping IDs.
            return $ids;
        }

        $unranked_rank = count($priority);
        $rows = [];
        foreach ($ids as $orig_index => $id) {
            $pt = $types[$id] ?? '';
            $rank = $pt !== '' ? array_search($pt, $priority, true) : false;
            if ($rank === false) {
                $rank = $unranked_rank;
            }
            $rows[] = ['id' => (int) $id, 'rank' => (int) $rank, 'orig' => (int) $orig_index];
        }

        // Stable sort: by (rank ASC, orig ASC). usort is not
        // guaranteed stable across PHP versions <= 7, but we
        // emulate stability by including the original index in the
        // comparison.
        usort($rows, static function (array $a, array $b): int {
            if ($a['rank'] !== $b['rank']) {
                return $a['rank'] - $b['rank'];
            }
            return $a['orig'] - $b['orig'];
        });

        $out = [];
        foreach ($rows as $r) {
            $out[] = $r['id'];
        }

        return $out;
    }

    /**
     * Resolved priority list. Reads the option, then runs it through
     * the `seekmodo_post_type_priority` filter so site-specific
     * mu-plugins can override.
     *
     * @return array<int,string> Lowercase post-type slugs in priority order.
     */
    public static function priority(): array
    {
        $raw = function_exists('get_option') ? get_option(Options::OPT_POST_TYPE_PRIORITY, '') : '';
        $list = [];
        if (is_array($raw)) {
            $list = $raw;
        } elseif (is_string($raw) && $raw !== '') {
            $list = explode(',', $raw);
        }
        $list = array_values(array_filter(array_map(static function ($v): string {
            return strtolower(trim((string) $v));
        }, $list), static function (string $v): bool {
            return $v !== '';
        }));

        if (function_exists('apply_filters')) {
            /** @var mixed $filtered */
            $filtered = apply_filters('seekmodo_post_type_priority', $list);
            if (is_array($filtered)) {
                $list = array_values(array_filter(array_map(static function ($v): string {
                    return strtolower(trim((string) $v));
                }, $filtered), static function (string $v): bool {
                    return $v !== '';
                }));
            }
        }

        return $list;
    }

    /**
     * Single SQL round-trip mapping post ID -> post_type for the
     * given IDs. Returns an empty map when wpdb is unavailable.
     *
     * @param array<int,int> $ids
     * @return array<int,string>
     */
    private static function lookupTypes(array $ids): array
    {
        global $wpdb;
        if (!isset($wpdb) || !is_object($wpdb) || empty($ids)) {
            return [];
        }
        $clean = array_values(array_unique(array_map('intval', $ids)));
        if (empty($clean)) {
            return [];
        }
        $placeholders = implode(',', array_fill(0, count($clean), '%d'));
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->posts is core table; IDs via prepare placeholders
        $sql = "SELECT ID, post_type FROM {$wpdb->posts} WHERE ID IN ($placeholders)";
        // Suppress null on prepare failure -- we'd rather no-op than throw.
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql uses %d placeholders; values passed to prepare()
        $prepared = $wpdb->prepare($sql, ...$clean);
        if (!is_string($prepared)) {
            return [];
        }
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $prepared is output of $wpdb->prepare()
        $rows = $wpdb->get_results($prepared);
        if (!is_array($rows)) {
            return [];
        }
        $out = [];
        foreach ($rows as $row) {
            if (is_object($row) && isset($row->ID, $row->post_type)) {
                $out[(int) $row->ID] = (string) $row->post_type;
            }
        }
        return $out;
    }
}
