<?php

/**
 * Schema Ranker
 *
 * Uniform keyword-match scorer that works on any schema.org entity — posts,
 * Products, LocalBusiness, FAQPage, HowTo, whatever the graph contains.
 *
 * One formula for every candidate so scores are directly comparable when
 * /ask merges post results with premium-enriched entities (LocalBusiness,
 * targeted FAQ, targeted HowTo). Without a uniform ranker, a rank-based
 * score on posts and a hit-count on enricher entities wouldn't be on the
 * same scale and sorting would be meaningless.
 *
 * Scoring model:
 *   - Tokenise the query (lower, alphanumerics, drop stopwords).
 *   - For each entity, gather searchable text with field weighting:
 *       name / headline            → weight 3
 *       description                 → weight 2
 *       any other nested string     → weight 1
 *   - Sum weighted hits across all tokens.
 *   - Soft-normalise to (0, 1) via score / (score + 5).
 *
 * Not BM25, not TF-IDF, not learned. Good enough for MVP ranking where the
 * intent is "surface entities whose text contains the query terms, weighted
 * toward the entity's identity."
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.3.1
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

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


class SchemaRanker {

    /**
     * Small English stopword list. Not used for indexing — only to prevent
     * "the", "is", etc. from dominating scores for short entities like
     * "The Product Name".
     */
    private const STOPWORDS = [
        'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at',
        'for', 'with', 'is', 'are', 'was', 'were', 'be', 'been', 'by',
        'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you',
        'we', 'they', 'as', 'if', 'do', 'does',
    ];

    /**
     * Max recursion depth when gathering searchable text from nested
     * entity structures (e.g. FAQPage.mainEntity[].acceptedAnswer.text).
     * Prevents runaway walks on @id-linked graphs with cycles.
     */
    private const MAX_DEPTH = 6;

    /**
     * Score an entity against a query.
     *
     * @param array  $entity Entity payload (as emitted by the collector / enricher).
     * @param string $query  Raw user query.
     * @return float Score in [0, 1).
     */
    public function score( array $entity, string $query ): float {
        $tokens = $this->tokenise( $query );
        if ( empty( $tokens ) ) {
            return 0.0;
        }

        $name = $this->lowercase( $this->stringify( $entity['name'] ?? $entity['headline'] ?? '' ) );
        $description = $this->lowercase( $this->stringify( $entity['description'] ?? '' ) );
        $other_text = $this->lowercase( $this->gather_other_text( $entity ) );

        $weighted = 0.0;
        foreach ( $tokens as $token ) {
            // Use substring match so morphological variants ("pizzeria"
            // matches "pizza") still hit without us running a stemmer.
            // Short tokens (<= 2 chars) only count as Unicode-word matches
            // to avoid false positives on fragment matches inside URLs.
            $is_fragment = $this->mb_strlen_safe( $token ) <= 2;

            if ( $name !== '' && $this->token_matches( $token, $name, $is_fragment ) ) {
                $weighted += 3.0;
            }
            if ( $description !== '' && $this->token_matches( $token, $description, $is_fragment ) ) {
                $weighted += 2.0;
            }
            if ( $other_text !== '' && $this->token_matches( $token, $other_text, $is_fragment ) ) {
                $weighted += 1.0;
            }
        }

        if ( $weighted <= 0 ) {
            return 0.0;
        }

        // Soft normaliser — score approaches 1 asymptotically. A strong match
        // (token hits name + description + body, across several tokens) lands
        // around 0.75–0.9; a weak single-field body hit lands around 0.15.
        return round( $weighted / ( $weighted + 5.0 ), 4 );
    }

    /**
     * Tokenise a query string into meaningful search tokens.
     *
     * Unicode-aware: supports non-Latin scripts and accented characters.
     * `mb_strtolower` handles casing for every script mbstring knows about,
     * and the split regex uses Unicode property classes so tokens stay
     * intact for Arabic, Japanese, Cyrillic, accented Latin, etc.
     *
     * @param string $query Raw query.
     * @return string[]
     */
    private function tokenise( string $query ): array {
        $normalised = $this->lowercase( wp_strip_all_tags( $query ) );
        // Split on anything that isn't a Letter (any script), Number, or
        // hyphen. The `u` flag is required so the class applies per-codepoint.
        $parts = preg_split( '/[^\p{L}\p{N}\-]+/u', $normalised );
        if ( ! is_array( $parts ) ) {
            return [];
        }
        $tokens = [];
        foreach ( $parts as $part ) {
            $part = trim( $part, '-' );
            if ( $part === '' ) {
                continue;
            }
            // Stopword list is English-only; non-English tokens simply
            // pass through without filtering, which is acceptable for MVP.
            if ( in_array( $part, self::STOPWORDS, true ) ) {
                continue;
            }
            $tokens[] = $part;
        }
        return array_values( array_unique( $tokens ) );
    }

    /**
     * Unicode-aware lowercase. Falls back to ASCII strtolower if mbstring
     * isn't available (very rare on modern WordPress hosts).
     *
     * @param string $value Input.
     * @return string
     */
    private function lowercase( string $value ): string {
        if ( function_exists( 'mb_strtolower' ) ) {
            return mb_strtolower( $value, 'UTF-8' );
        }
        return strtolower( $value );
    }

    /**
     * Multibyte-safe string length, falling back to byte count.
     *
     * @param string $value Input.
     * @return int
     */
    private function mb_strlen_safe( string $value ): int {
        if ( function_exists( 'mb_strlen' ) ) {
            return (int) mb_strlen( $value, 'UTF-8' );
        }
        return strlen( $value );
    }

    /**
     * Gather non-name/non-description text from an entity for scoring.
     *
     * Walks nested arrays up to MAX_DEPTH. Strings are concatenated with a
     * space separator so boundaries are preserved. Skips `@context`, `@id`,
     * `@type`, `url`, date fields, and already-extracted name/description
     * to avoid double-counting or polluting the search with URLs and dates.
     *
     * @param array $entity Entity payload.
     * @return string
     */
    private function gather_other_text( array $entity ): string {
        $skip_keys = [ '@context', '@id', '@type', 'url', 'contentUrl',
                       'name', 'headline', 'description',
                       'datePublished', 'dateModified', 'uploadDate',
                       'width', 'height', 'image' ];
        $pieces = [];
        $this->walk_for_text( $entity, $pieces, $skip_keys, 0 );
        return implode( ' ', $pieces );
    }

    /**
     * Recursive text collector.
     *
     * @param mixed    $value     Value to inspect.
     * @param string[] &$pieces   Accumulator of discovered strings.
     * @param string[] $skip_keys Keys whose values are skipped at the top level.
     * @param int      $depth     Current recursion depth.
     * @return void
     */
    private function walk_for_text( $value, array &$pieces, array $skip_keys, int $depth ): void {
        if ( $depth >= self::MAX_DEPTH ) {
            return;
        }
        if ( is_string( $value ) ) {
            $trimmed = trim( $value );
            if ( $trimmed !== '' ) {
                $pieces[] = $trimmed;
            }
            return;
        }
        if ( ! is_array( $value ) ) {
            return;
        }
        foreach ( $value as $key => $child ) {
            if ( is_string( $key ) && in_array( $key, $skip_keys, true ) && $depth === 0 ) {
                continue;
            }
            $this->walk_for_text( $child, $pieces, $skip_keys, $depth + 1 );
        }
    }

    /**
     * Whether a token matches within a text field.
     *
     * For multi-char tokens, uses `str_contains` so morphological variants
     * match ("run" hits "runtime"). For short tokens (<= 2 chars), requires
     * a Unicode word boundary to avoid spurious fragment matches — the
     * `u` flag alone doesn't make `\b` Unicode-aware, so we use explicit
     * `\P{L}` (non-letter) lookaround sentinels that work across scripts.
     *
     * @param string $token       Lowercase token.
     * @param string $haystack    Lowercase text.
     * @param bool   $whole_word  When true, require word-boundary match.
     * @return bool
     */
    private function token_matches( string $token, string $haystack, bool $whole_word ): bool {
        if ( $whole_word ) {
            $pattern = '/(?<![\p{L}\p{N}])' . preg_quote( $token, '/' ) . '(?![\p{L}\p{N}])/u';
            return (bool) preg_match( $pattern, $haystack );
        }
        return str_contains( $haystack, $token );
    }

    /**
     * Coerce any value into a string safely.
     *
     * @param mixed $value Value.
     * @return string
     */
    private function stringify( $value ): string {
        if ( is_string( $value ) ) {
            return $value;
        }
        if ( is_scalar( $value ) ) {
            return (string) $value;
        }
        if ( is_array( $value ) ) {
            $collected = [];
            foreach ( $value as $item ) {
                if ( is_string( $item ) || is_scalar( $item ) ) {
                    $collected[] = (string) $item;
                }
            }
            return implode( ' ', $collected );
        }
        return '';
    }
}
