<?php

/**
 * Post Search Engine
 *
 * Thin wrapper around WP_Query's relevance-ordered search, used by the
 * /ask endpoint to pick the top posts for a natural-language query.
 *
 * Deliberately simple for MVP list mode:
 *   - Relies on WordPress's built-in fulltext-ish search (`s=`)
 *   - Filters matches through PostEntityBuilder::is_post_eligible so the
 *     schema-map's exclusion rules apply (noindex, non-indexable types,
 *     non-published) — the /ask results and /schema-map can never disagree
 *     about whether a given post is eligible to surface
 *   - Assigns a rank-based score normalized to (0, 1]
 *
 * Not in scope for MVP: TF-IDF, BM25, vector search, LLM re-ranking.
 * That's what `mode=summarize` / `mode=generate` are for later.
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.3.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

use WP_Post;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}


class PostSearchEngine {

    /**
     * Default number of results returned when `limit` isn't specified.
     */
    public const DEFAULT_LIMIT = 10;

    /**
     * Hard cap on `limit` — higher values would push payloads past sane
     * bounds and defeat the point of ranked retrieval.
     */
    public const MAX_LIMIT = 50;

    /**
     * Shared entity builder for eligibility checks + post-type list.
     *
     * @var PostEntityBuilder
     */
    private PostEntityBuilder $builder;

    /**
     * Constructor.
     *
     * @param PostEntityBuilder|null $builder Optional injection for tests.
     */
    public function __construct( ?PostEntityBuilder $builder = null ) {
        $this->builder = $builder ?? new PostEntityBuilder();
    }

    /**
     * Run a search and return ranked matches.
     *
     * @param string $query Raw natural-language query.
     * @param int    $limit Maximum results (clamped to [1, MAX_LIMIT]).
     * @return array<int, array{post: WP_Post, score: float, rank: int}>
     */
    public function search( string $query, int $limit = self::DEFAULT_LIMIT ): array {
        $query = trim( $query );
        if ( $query === '' ) {
            return [];
        }

        $limit = max( 1, min( self::MAX_LIMIT, $limit ) );

        $post_types = $this->builder->indexable_post_types();
        if ( empty( $post_types ) ) {
            return [];
        }

        $args = [
            's' => $query,
            'post_type' => $post_types,
            'post_status' => 'publish',
            // Request more than we need so filtering for eligibility still
            // leaves us with enough candidates to hit `limit`.
            'posts_per_page' => $limit * 3,
            'orderby' => 'relevance',
            'order' => 'DESC',
            'has_password' => false,
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'suppress_filters' => false,
        ];

        /**
         * Filter the WP_Query args used by the /ask engine.
         *
         * Parallels `prorank_schema_map_query_args` for the collector, so
         * site integrators can apply the same scope adjustments to search
         * results as they do to the aggregated graph.
         *
         * @param array  $args  WP_Query args.
         * @param string $query The raw query string.
         * @param int    $limit Result cap.
         */
        $args = apply_filters( 'prorank_schema_ask_query_args', $args, $query, $limit );

        $wp_query = new \WP_Query( $args );
        $matches = [];
        $rank = 0;

        if ( $wp_query->have_posts() ) {
            foreach ( $wp_query->posts as $post ) {
                if ( ! ( $post instanceof WP_Post ) ) {
                    continue;
                }
                if ( ! $this->builder->is_post_eligible( $post ) ) {
                    continue;
                }
                $matches[] = [
                    'post' => $post,
                    'rank' => $rank,
                    'score' => 0.0, // filled in below once we know the final count
                ];
                $rank++;
                if ( count( $matches ) >= $limit ) {
                    break;
                }
            }
        }

        wp_reset_postdata();

        // Rank-normalised score: first result = 1.0, last = 1 / count.
        // Bounded in (0, 1]. Not a likelihood — just a stable ordering value
        // a caller can treat as "higher = more relevant".
        $count = count( $matches );
        foreach ( $matches as $i => &$match ) {
            $match['score'] = $count > 0 ? round( ( $count - $i ) / $count, 4 ) : 0.0;
        }
        unset( $match );

        return $matches;
    }
}
