<?php

declare(strict_types=1);

/**
 * Prominent Words / Topical Authority Analyzer (Task 8).
 *
 * Read-only service that derives the top N meaningful terms from a post's
 * title + content. Output is stored in post meta so editor UI, future
 * internal-link suggestions, and bulk audits can read it cheaply.
 *
 * Design notes:
 *
 *   - No AI, no external calls. Pure deterministic tokenization.
 *   - HTML, shortcodes, <script>/<style>/<code>/<pre>/<a>/<head>, URLs and
 *     emails are stripped BEFORE tokenization so an internal link's anchor
 *     text never inflates the host post's prominence score.
 *   - Stop-word and short-term filters are conservative; a curated English
 *     stop-word list ships by default and can be extended via the
 *     `prorank_prominent_words_stop_words` filter.
 *   - Cache uses a rich signature (version + content + title + ignored set
 *     + stop-words set + max_terms). Any change invalidates the cached
 *     terms — same shape used in T7 to avoid stale state when settings or
 *     filters change.
 *
 * @package ProRank\SEO\Core\Content
 * @since   1.3.1
 */

namespace ProRank\SEO\Core\Content;

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


class ProminentWordsAnalyzer {

    /**
     * Bump when tokenization rules or weighting change so stored indexes
     * re-build on the next render/save.
     */
    public const ANALYZER_VERSION = 1;

    public const META_SIGNATURE = '_prorank_pw_signature';
    public const META_TERMS     = '_prorank_pw_terms';
    public const META_VERSION   = '_prorank_pw_version';

    public const DEFAULT_MAX_TERMS = 20;
    public const MIN_MAX_TERMS     = 5;
    public const MAX_MAX_TERMS     = 50;

    public const MIN_TERM_LENGTH = 3;

    /**
     * Title terms count for more — words in the title are a stronger
     * topical signal than incidental body mentions.
     */
    public const TITLE_WEIGHT = 3;

    /**
     * Hard cap per bulk indexing call (REST + scheduled).
     */
    public const BULK_CAP = 50;

    /**
     * Curated English stop-word starter set. Filterable by
     * `prorank_prominent_words_stop_words` so localized installs can swap
     * the list wholesale.
     */
    private const DEFAULT_STOP_WORDS = [
        'the','a','an','and','or','but','if','then','than','as','at','by','for',
        'from','in','into','of','on','onto','to','with','without','about','over',
        'under','between','among','through','during','after','before','since',
        'until','while','where','when','why','how','what','which','who','whom',
        'whose','this','that','these','those','it','its','itself','they','them',
        'their','theirs','themselves','he','him','his','himself','she','her',
        'hers','herself','we','us','our','ours','ourselves','you','your','yours',
        'yourself','yourselves','i','me','my','mine','myself',
        'is','are','was','were','be','been','being','am',
        'do','does','did','doing','done',
        'have','has','had','having',
        'can','could','should','would','may','might','must','shall','will','wo','ca',
        'not','no','nor','so','too','very','also','only','just','even','ever','never',
        'one','two','three','four','five','six','seven','eight','nine','ten',
        'all','any','some','few','many','much','more','most','other','others','same',
        'such','own','here','there','out','up','down','off','again','further','once',
        'because','though','although','whether','either','neither','both',
        'now','today','yesterday','tomorrow','soon','later','well','say','says',
        'said','get','gets','got','make','makes','made','take','takes','took','use',
        'used','using','like','etc','vs','via',
        // HTML/word leftovers from sloppy markup
        'http','https','www','com',
    ];

    /**
     * Public entry point. Pure: takes a title + content and returns the
     * indexed payload. No side effects.
     *
     * @param string $title
     * @param string $content
     * @param array  $config {
     *   @type array $ignored_terms  Lowercased phrases to drop entirely.
     *   @type int   $max_terms      Hard cap on returned terms.
     * }
     * @return array{
     *   version:int,
     *   total_unique:int,
     *   total_tokens:int,
     *   terms:array<int,array{term:string,count:int,weight:int,from_title:bool}>
     * }
     */
    public static function analyze( string $title, string $content, array $config = [] ): array {
        $max_terms = self::clamp_max_terms( $config['max_terms'] ?? self::DEFAULT_MAX_TERMS );

        $ignored = self::normalize_terms( (array) ( $config['ignored_terms'] ?? [] ) );
        $stop_words = self::get_stop_words();
        $ignored_set = array_flip( $ignored );
        $stop_set = array_flip( $stop_words );

        $title_clean = self::clean_for_analysis( $title );
        $body_clean  = self::clean_for_analysis( $content );

        $title_tokens = self::tokenize( $title_clean );
        $body_tokens  = self::tokenize( $body_clean );

        $title_set = [];
        foreach ( $title_tokens as $t ) {
            $title_set[ $t ] = true;
        }

        $counts = [];
        $total_tokens = 0;

        $eat = static function ( string $term, int $weight ) use ( &$counts, &$total_tokens, $ignored_set, $stop_set ) {
            if ( isset( $ignored_set[ $term ] ) || isset( $stop_set[ $term ] ) ) {
                return;
            }
            if ( strlen( $term ) < self::MIN_TERM_LENGTH ) {
                return;
            }
            $counts[ $term ] = ( $counts[ $term ] ?? 0 ) + $weight;
            $total_tokens += $weight;
        };
        foreach ( $title_tokens as $t ) {
            $eat( $t, self::TITLE_WEIGHT );
        }
        foreach ( $body_tokens as $t ) {
            $eat( $t, 1 );
        }

        // Sort by weight desc, then term asc, then take top N.
        $terms = [];
        foreach ( $counts as $term => $weight ) {
            $terms[] = [
                'term'       => $term,
                'count'      => (int) $weight,
                'weight'     => (int) $weight,
                'from_title' => isset( $title_set[ $term ] ),
            ];
        }
        usort( $terms, static function ( $a, $b ) {
            if ( $a['weight'] === $b['weight'] ) {
                return strcmp( $a['term'], $b['term'] );
            }
            return $b['weight'] <=> $a['weight'];
        } );

        $total_unique = count( $terms );
        if ( $total_unique > $max_terms ) {
            $terms = array_slice( $terms, 0, $max_terms );
        }

        return [
            'version'      => self::ANALYZER_VERSION,
            'total_unique' => $total_unique,
            'total_tokens' => $total_tokens,
            'terms'        => $terms,
        ];
    }

    /**
     * Indexing entry point used by save_post, REST, and the bulk runner.
     *
     * Returns a structured outcome so callers can report on bulk runs
     * without having to re-read meta themselves.
     */
    public static function index_post( int $post_id, bool $force = false ): array {
        $outcome = [
            'post_id'   => $post_id,
            'indexed'   => false,
            'skipped'   => false,
            'reason'    => '',
            'signature' => '',
            'terms'     => [],
        ];
        if ( $post_id <= 0 ) {
            $outcome['skipped'] = true;
            $outcome['reason']  = 'invalid_id';
            return $outcome;
        }
        if ( ! self::is_post_eligible( $post_id ) ) {
            // Eligibility dropped — make sure the stored meta is cleared
            // so a previously-indexed page that just turned private/noindex
            // does not keep showing prominent words in the editor panel.
            self::clear_post( $post_id );
            $outcome['skipped'] = true;
            $outcome['reason']  = 'ineligible';
            return $outcome;
        }
        $settings = self::get_settings();
        if ( empty( $settings['enabled'] ) ) {
            self::clear_post( $post_id );
            $outcome['skipped'] = true;
            $outcome['reason']  = 'disabled';
            return $outcome;
        }
        $title   = (string) get_post_field( 'post_title', $post_id, 'raw' );
        $content = (string) get_post_field( 'post_content', $post_id, 'raw' );

        $signature = self::cache_signature( $title, $content, $settings );
        if ( ! $force ) {
            $cached = (string) get_post_meta( $post_id, self::META_SIGNATURE, true );
            if ( $cached === $signature ) {
                $outcome['skipped']   = true;
                $outcome['reason']    = 'unchanged';
                $outcome['signature'] = $signature;
                $outcome['terms']     = self::read_terms( $post_id );
                return $outcome;
            }
        }
        $result = self::analyze( $title, $content, [
            'ignored_terms' => $settings['ignored_terms'],
            'max_terms'     => $settings['max_terms'],
        ] );
        update_post_meta( $post_id, self::META_TERMS, $result['terms'] );
        update_post_meta( $post_id, self::META_SIGNATURE, $signature );
        update_post_meta( $post_id, self::META_VERSION, self::ANALYZER_VERSION );

        $outcome['indexed']   = true;
        $outcome['signature'] = $signature;
        $outcome['terms']     = $result['terms'];
        return $outcome;
    }

    /**
     * Bulk indexing — capped at BULK_CAP per call. Used by the manual REST
     * action and any future scheduled runner. Returns a summary so the UI
     * can show progress without re-querying.
     */
    public static function bulk_index( int $limit = self::BULK_CAP, int $offset = 0 ): array {
        $limit  = max( 1, min( self::BULK_CAP, $limit ) );
        $offset = max( 0, $offset );

        $settings = self::get_settings();
        if ( empty( $settings['enabled'] ) ) {
            return [
                'enabled'   => false,
                'scanned'   => 0,
                'indexed'   => 0,
                'skipped'   => 0,
                'next'      => null,
                'cap'       => self::BULK_CAP,
            ];
        }

        $post_types = self::get_supported_post_types();
        $query_args = [
            'post_type'        => $post_types,
            'post_status'      => 'publish',
            'posts_per_page'   => $limit,
            'offset'           => $offset,
            'orderby'          => 'modified',
            'order'            => 'DESC',
            'no_found_rows'    => true,
            'suppress_filters' => false,
            'fields'           => 'ids',
        ];
        $query_args = apply_filters( 'prorank_prominent_words_bulk_query_args', $query_args );

        $query  = new \WP_Query( $query_args );
        $ids    = $query->posts ?? [];

        $scanned = 0;
        $indexed = 0;
        $skipped = 0;
        foreach ( (array) $ids as $pid ) {
            $scanned++;
            $r = self::index_post( (int) $pid, false );
            if ( ! empty( $r['indexed'] ) ) {
                $indexed++;
            } else {
                $skipped++;
            }
        }

        return [
            'enabled' => true,
            'scanned' => $scanned,
            'indexed' => $indexed,
            'skipped' => $skipped,
            'next'    => $scanned === $limit ? ( $offset + $limit ) : null,
            'cap'     => self::BULK_CAP,
        ];
    }

    /**
     * Read the stored payload for a post and report staleness.
     *
     * @return array{enabled:bool,indexed:bool,stale:bool,version:int,signature:string,terms:array<int,array>}
     */
    public static function get_post_index( int $post_id ): array {
        $settings = self::get_settings();
        $payload = [
            'enabled'   => (bool) $settings['enabled'],
            'indexed'   => false,
            'stale'     => false,
            'version'   => self::ANALYZER_VERSION,
            'signature' => '',
            'terms'     => [],
        ];
        if ( $post_id <= 0 || ! $settings['enabled'] ) {
            return $payload;
        }
        $stored_terms = self::read_terms( $post_id );
        $stored_sig   = (string) get_post_meta( $post_id, self::META_SIGNATURE, true );
        $eligible     = self::is_post_eligible( $post_id );

        // R2 — eligibility must gate reads even when stale meta exists.
        // A post can be turned noindex / private / draft via paths that do
        // NOT fire save_post (e.g. MetaController writing _prorank_meta_robots
        // directly through update_post_meta, or a bulk DB-level change).
        // Clear stale meta inline and report indexed=false so the UI does
        // not keep showing terms for content the user just hid.
        if ( ! $eligible ) {
            if ( ! empty( $stored_terms ) || $stored_sig !== '' ) {
                self::clear_post( $post_id );
            }
            return $payload;
        }

        // R1 safety net: an eligible post that has never been indexed
        // (e.g. historic content, or a post that just turned eligible)
        // should be indexed lazily on the first read instead of leaving
        // the panel empty. Bounded strictly to this one post.
        if ( empty( $stored_terms ) && $stored_sig === '' ) {
            $r = self::index_post( $post_id, true );
            if ( ! empty( $r['indexed'] ) ) {
                $payload['indexed']   = true;
                $payload['signature'] = (string) ( $r['signature'] ?? '' );
                $payload['stale']     = false;
                $payload['terms']     = self::read_terms( $post_id );
            }
            return $payload;
        }

        $title   = (string) get_post_field( 'post_title', $post_id, 'raw' );
        $content = (string) get_post_field( 'post_content', $post_id, 'raw' );
        $live_sig = self::cache_signature( $title, $content, $settings );

        // R2 — signature mismatch means content drift OR analyzer-version
        // bump OR stop-words/ignored-set change. Rebuild this one post
        // lazily instead of returning stale terms; acceptance explicitly
        // requires version/signature mismatch to re-index. Bounded to a
        // single post — bulk re-indexing has its own /bulk-index path.
        if ( $stored_sig !== $live_sig ) {
            $r = self::index_post( $post_id, true );
            if ( ! empty( $r['indexed'] ) ) {
                $payload['indexed']   = true;
                $payload['signature'] = (string) ( $r['signature'] ?? '' );
                $payload['stale']     = false;
                $payload['terms']     = self::read_terms( $post_id );
                return $payload;
            }
            // Rebuild refused (e.g. eligibility flipped between checks).
            // Fall through to the stale-but-stored response so the UI can
            // surface the existing terms with stale=true rather than going
            // blank on a transient failure.
        }

        $payload['indexed']   = true;
        $payload['signature'] = $stored_sig;
        $payload['stale']     = $stored_sig !== $live_sig;
        $payload['terms']     = $stored_terms;
        return $payload;
    }

    /**
     * Drop a single post's index. Hooked from `save_post` so the next render
     * refreshes (or, when called from index_post when force=true, gets
     * immediately overwritten with fresh data).
     */
    public static function clear_post( int $post_id ): void {
        if ( $post_id <= 0 ) {
            return;
        }
        delete_post_meta( $post_id, self::META_SIGNATURE );
        delete_post_meta( $post_id, self::META_TERMS );
        delete_post_meta( $post_id, self::META_VERSION );
    }

    /**
     * Bulk-clear the index for every post. Used when settings or filters
     * change in a way the per-post signature alone cannot recover from
     * (e.g. analyzer version bump, ignored-set change, stop-words swap).
     */
    public static function flush_all(): void {
        if ( function_exists( 'delete_post_meta_by_key' ) ) {
            delete_post_meta_by_key( self::META_SIGNATURE );
            delete_post_meta_by_key( self::META_TERMS );
            delete_post_meta_by_key( self::META_VERSION );
        }
    }

    /**
     * Register cache-invalidation + re-index hooks at plugin boot.
     */
    public static function register_hooks(): void {
        if ( ! function_exists( 'add_action' ) ) {
            return;
        }
        static $registered = false;
        if ( $registered ) {
            return;
        }
        $registered = true;
        add_action( 'save_post',    [ self::class, 'on_save_post' ], 20, 2 );
        add_action( 'deleted_post', [ self::class, 'clear_post' ] );
        add_action( 'update_option_prorank_seo_content_analysis', [ self::class, 'on_settings_updated' ], 10, 2 );
    }

    /**
     * `save_post` handler — re-index eligible public posts, drop the index
     * for everything else. Skips revisions and autosaves to stay quiet.
     *
     * R1: previously cleared only and relied on a "lazy rebuild" path that
     * did not exist. We now index_post() directly on save for eligible
     * posts so a freshly-saved post immediately shows terms in the editor
     * panel and REST response (no manual /prominent-words/index roundtrip
     * required). Ineligible posts still get their stale meta cleared.
     */
    public static function on_save_post( $post_id, $post = null ): void {
        $post_id = (int) $post_id;
        if ( $post_id <= 0 ) {
            return;
        }
        if ( function_exists( 'wp_is_post_revision' ) && wp_is_post_revision( $post_id ) ) {
            return;
        }
        if ( function_exists( 'wp_is_post_autosave' ) && wp_is_post_autosave( $post_id ) ) {
            return;
        }
        // index_post() handles both branches: eligible+enabled → fresh
        // index meta written; ineligible/disabled → stale meta cleared
        // and the result reports the skip reason. force=true so a fresh
        // save always rewrites even when the signature appears unchanged
        // (defensive against partial cache states).
        self::index_post( $post_id, true );
    }

    /**
     * Settings-updated handler — bulk-flush the index when an ingredient of
     * the signature actually changed; ignore unrelated content-analysis
     * setting edits to avoid an unnecessary global re-index.
     */
    public static function on_settings_updated( $old_value, $new_value ): void {
        $old_value = is_array( $old_value ) ? $old_value : [];
        $new_value = is_array( $new_value ) ? $new_value : [];

        $old_enabled = array_key_exists( 'enable_prominent_words', $old_value )
            ? ! empty( $old_value['enable_prominent_words'] )
            : true;
        $new_enabled = array_key_exists( 'enable_prominent_words', $new_value )
            ? ! empty( $new_value['enable_prominent_words'] )
            : true;

        $old_max = (int) ( $old_value['prominent_words_max_terms'] ?? self::DEFAULT_MAX_TERMS );
        $new_max = (int) ( $new_value['prominent_words_max_terms'] ?? self::DEFAULT_MAX_TERMS );

        $old_ignored = self::normalize_terms( (array) ( $old_value['prominent_words_ignored_terms'] ?? [] ) );
        $new_ignored = self::normalize_terms( (array) ( $new_value['prominent_words_ignored_terms'] ?? [] ) );

        if ( $old_enabled === $new_enabled
            && $old_max === $new_max
            && $old_ignored === $new_ignored
        ) {
            return;
        }
        self::flush_all();
    }

    /**
     * Read effective settings, applying conservative defaults and clamping.
     *
     * @return array{
     *   enabled:bool,
     *   max_terms:int,
     *   ignored_terms:array<int,string>
     * }
     */
    public static function get_settings(): array {
        $stored = function_exists( 'get_option' ) ? get_option( 'prorank_seo_content_analysis', [] ) : [];
        if ( ! is_array( $stored ) ) {
            $stored = [];
        }
        $enabled = array_key_exists( 'enable_prominent_words', $stored )
            ? ! empty( $stored['enable_prominent_words'] )
            : true;
        $max_terms = self::clamp_max_terms( $stored['prominent_words_max_terms'] ?? self::DEFAULT_MAX_TERMS );
        $ignored = self::normalize_terms( (array) ( $stored['prominent_words_ignored_terms'] ?? [] ) );
        return [
            'enabled'       => (bool) $enabled,
            'max_terms'     => $max_terms,
            'ignored_terms' => $ignored,
        ];
    }

    /**
     * Stop-words list, filter-friendly so localized installs can replace it.
     *
     * @return array<int,string>
     */
    public static function get_stop_words(): array {
        $words = self::DEFAULT_STOP_WORDS;
        if ( function_exists( 'apply_filters' ) ) {
            $filtered = apply_filters( 'prorank_prominent_words_stop_words', $words );
            if ( is_array( $filtered ) ) {
                $words = array_map( static function ( $w ) {
                    return is_scalar( $w ) ? strtolower( trim( (string) $w ) ) : '';
                }, $filtered );
            }
        }
        $words = array_values( array_filter( array_unique( $words ), static function ( $w ) {
            return is_string( $w ) && $w !== '';
        } ) );
        sort( $words, SORT_STRING );
        return $words;
    }

    /**
     * Default supported post types — public, viewable, filterable.
     *
     * @return array<int,string>
     */
    public static function get_supported_post_types(): array {
        $defaults = [ 'post', 'page' ];
        if ( function_exists( 'apply_filters' ) ) {
            $filtered = apply_filters( 'prorank_prominent_words_post_types', $defaults );
            if ( is_array( $filtered ) ) {
                $defaults = array_values( array_unique( array_filter( $filtered, 'is_string' ) ) ) ?: $defaults;
            }
        }
        return $defaults;
    }

    /**
     * Eligibility check: published, supported post-type, not noindex, and
     * subject to the `prorank_prominent_words_should_index` filter so
     * third-party plugins (incl. ProRank's own SEO metadata module) can
     * exclude a post without monkeypatching this analyzer.
     */
    public static function is_post_eligible( int $post_id ): bool {
        if ( $post_id <= 0 ) {
            return false;
        }
        $post = function_exists( 'get_post' ) ? get_post( $post_id ) : null;
        if ( ! $post ) {
            return false;
        }
        $eligible = true;
        if ( ! in_array( $post->post_type ?? '', self::get_supported_post_types(), true ) ) {
            $eligible = false;
        }
        if ( $eligible && ( $post->post_status ?? '' ) !== 'publish' ) {
            $eligible = false;
        }
        if ( $eligible && self::post_is_noindex( $post_id ) ) {
            $eligible = false;
        }
        if ( function_exists( 'apply_filters' ) ) {
            $eligible = (bool) apply_filters( 'prorank_prominent_words_should_index', $eligible, $post );
        }
        return $eligible;
    }

    // ---------------------------------------------------------------
    // Internals
    // ---------------------------------------------------------------

    private static function read_terms( int $post_id ): array {
        $stored = get_post_meta( $post_id, self::META_TERMS, true );
        if ( ! is_array( $stored ) ) {
            return [];
        }
        // Filter to the expected shape so a manually-edited meta value
        // cannot break the REST contract.
        $out = [];
        foreach ( $stored as $row ) {
            if ( ! is_array( $row ) || empty( $row['term'] ) ) {
                continue;
            }
            $out[] = [
                'term'       => (string) $row['term'],
                'count'      => (int) ( $row['count'] ?? 0 ),
                'weight'     => (int) ( $row['weight'] ?? ( $row['count'] ?? 0 ) ),
                'from_title' => ! empty( $row['from_title'] ),
            ];
        }
        return $out;
    }

    private static function cache_signature( string $title, string $content, array $settings ): string {
        $payload = [
            'v'       => self::ANALYZER_VERSION,
            'title'   => sha1( $title ),
            'content' => sha1( $content ),
            'max'     => (int) $settings['max_terms'],
            'ignored' => $settings['ignored_terms'],
            'stop'    => sha1( implode( "\n", self::get_stop_words() ) ),
        ];
        $encoded = function_exists( 'wp_json_encode' )
            ? wp_json_encode( $payload )
            : json_encode( $payload );
        return sha1( (string) $encoded );
    }

    private static function normalize_terms( array $terms ): array {
        $seen = [];
        foreach ( $terms as $t ) {
            $t = is_scalar( $t ) ? strtolower( trim( (string) $t ) ) : '';
            if ( $t !== '' ) {
                $seen[ $t ] = true;
            }
        }
        $out = array_keys( $seen );
        sort( $out, SORT_STRING );
        return $out;
    }

    private static function clamp_max_terms( $value ): int {
        $value = (int) $value;
        if ( $value < self::MIN_MAX_TERMS ) {
            return self::MIN_MAX_TERMS;
        }
        if ( $value > self::MAX_MAX_TERMS ) {
            return self::MAX_MAX_TERMS;
        }
        return $value;
    }

    /**
     * Strip everything we do not want counted — anchor text + their
     * contents, all script/style/code/pre, shortcodes, then HTML tags,
     * then URLs and emails.
     */
    private static function clean_for_analysis( string $text ): string {
        if ( $text === '' ) {
            return '';
        }
        $text = preg_replace(
            '#<(script|style|code|pre|a|head)\b[^>]*>.*?</\\1\s*>#isu',
            ' ',
            $text
        ) ?? $text;
        $text = preg_replace( '/\[(\w[\w-]*)(?:[^\]]*)\](?:.*?\[\/\1\])?/s', ' ', $text ) ?? $text;
        $text = preg_replace( '/\[\/?\w[\w-]*(?:[^\]]*)\]/', ' ', $text ) ?? $text;
        $text = preg_replace( '/<[^>]*>/u', ' ', $text ) ?? $text;
        $text = preg_replace( '#https?://\S+#u', ' ', $text ) ?? $text;
        $text = preg_replace( '/\b[\w.+\-]+@[\w.\-]+\.[\w.\-]+\b/u', ' ', $text ) ?? $text;
        $text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $text = preg_replace( '/\s+/u', ' ', (string) $text ) ?? '';
        return trim( (string) $text );
    }

    /**
     * Lowercase, unicode-aware tokenizer. Allows internal apostrophes and
     * hyphens so words like "don't" and "self-hosted" survive, but strips
     * leading/trailing punctuation.
     *
     * @return array<int,string>
     */
    private static function tokenize( string $text ): array {
        if ( $text === '' ) {
            return [];
        }
        $text = function_exists( 'mb_strtolower' )
            ? mb_strtolower( $text, 'UTF-8' )
            : strtolower( $text );

        if ( ! preg_match_all( "/[\\p{L}\\p{N}][\\p{L}\\p{N}'\\-]*/u", $text, $m ) ) {
            return [];
        }
        $tokens = [];
        foreach ( $m[0] as $tok ) {
            $tok = trim( $tok, "-'" );
            if ( $tok === '' ) {
                continue;
            }
            // Reject all-digit and ordinal-like tokens.
            if ( preg_match( '/^[0-9]+(st|nd|rd|th)?$/u', $tok ) ) {
                continue;
            }
            $tokens[] = $tok;
        }
        return $tokens;
    }

    private static function post_is_noindex( int $post_id ): bool {
        // R1: ProRank's actual per-post robots storage is
        // `_prorank_meta_robots` — an array that may contain 'noindex'
        // alongside 'nofollow' etc. The TitlesMetaModule writes this key
        // and MetaController reads/writes it; we honor it directly.
        $pr = get_post_meta( $post_id, '_prorank_meta_robots', true );
        if ( is_array( $pr ) && in_array( 'noindex', $pr, true ) ) {
            return true;
        }
        // Legacy boolean keys some installs may still carry — keep for
        // defensive compatibility but the array key above is canonical.
        foreach ( [ '_prorank_seo_robots_noindex', '_prorank_robots_noindex' ] as $k ) {
            $v = get_post_meta( $post_id, $k, true );
            if ( $v === '1' || $v === 1 || $v === true || $v === 'true' ) {
                return true;
            }
        }
        // Honor Yoast/RankMath/AIOSEO when present so a user's existing
        // noindex pages do not get crawled by our indexer either.
        $aliases = [
            '_yoast_wpseo_meta-robots-noindex',
            'rank_math_robots',
            '_aioseo_noindex',
        ];
        foreach ( $aliases as $k ) {
            $v = get_post_meta( $post_id, $k, true );
            if ( $v === '1' || $v === 1 ) {
                return true;
            }
            if ( is_array( $v ) && in_array( 'noindex', $v, true ) ) {
                return true;
            }
        }
        return false;
    }
}
