<?php

// phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in, WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
/**
 * Internal Linking Suggestions
 *
 * Provides basic internal linking suggestions for content.
 *
 * @package ProRank\SEO\Modules\Content
 * @since   1.0.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\Content;

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




defined( 'ABSPATH' ) || exit;

use WP_Query;
use ProRank\SEO\Core\FocusKeywords;
use ProRank\SEO\Modules\Content\InternalLinking\SettingsStore;

/**
 * InternalLinking class
 */
class InternalLinking {
    /**
     * Maximum number of posts to scan for suggestions
     */
    private const MAX_POSTS_TO_SCAN = 150;

    /**
     * Maximum number of suggestions to return
     */
    private const MAX_SUGGESTIONS = 10;

    /**
     * Minimum anchor text length
     */
    private const MIN_ANCHOR_LENGTH = 3;

    /**
     * Maximum anchor text length
     */
    private const MAX_ANCHOR_LENGTH = 120;

    /**
     * Maximum phrases extracted from content
     */
    private const MAX_PHRASES = 60;

    /**
     * Get internal linking settings with defaults applied.
     *
     * @return array
     */
    private static function get_internal_linking_settings(): array {
        $defaults = [
            'enabled' => true,
            'max_links_per_post' => self::MAX_SUGGESTIONS,
            'min_word_count' => 100,
            'link_post_types' => ['post', 'page'],
            'post_types' => ['post', 'page'],
            'exclude_categories' => [],
            'ignore_words' => '',
        ];

        $settings = SettingsStore::get($defaults);

        if (
            (!isset($settings['link_post_types']) || !is_array($settings['link_post_types']) || empty($settings['link_post_types']))
            && isset($settings['post_types']) && is_array($settings['post_types']) && !empty($settings['post_types'])
        ) {
            $settings['link_post_types'] = $settings['post_types'];
        }

        if (
            (!isset($settings['post_types']) || !is_array($settings['post_types']) || empty($settings['post_types']))
            && isset($settings['link_post_types']) && is_array($settings['link_post_types']) && !empty($settings['link_post_types'])
        ) {
            $settings['post_types'] = $settings['link_post_types'];
        }

        return $settings;
    }

    /**
     * Get basic internal linking suggestions
     *
     * @param int    $current_post_id      Current post ID.
     * @param string $current_post_content Current post content.
     * @param string $current_post_title   Current post title.
     * @return array Array of suggestions with title, url, and suggested_anchor.
     */
    public static function get_basic_suggestions(
        int $current_post_id,
        string $current_post_content,
        string $current_post_title
    ): array {
        $content_without_links = preg_replace('/<a\b[^>]*>.*?<\/a>/is', ' ', $current_post_content);
        $cleaned_content = wp_strip_all_tags($content_without_links);
        $settings = self::get_internal_linking_settings();
        if (isset($settings['enabled']) && !$settings['enabled']) {
            return [];
        }
        $ignore_words = array_merge(
            self::parse_ignore_words((string) ($settings['ignore_words'] ?? '')),
            self::parse_ignored_words_text((string) ($settings['ignored_words'] ?? ''))
        );
        $ignore_words = array_values(array_unique(array_filter($ignore_words)));
        $ignore_numbers = !empty($settings['ignore_numbers']);
        $max_suggestions = self::get_max_suggestions($settings);

        $min_word_count = (int) ($settings['min_word_count'] ?? 0);
        if ($min_word_count > 0 && str_word_count($cleaned_content) < $min_word_count) {
            return [];
        }

        $content_phrases = self::extract_phrases($cleaned_content);
        $content_keywords = self::get_significant_words_from_content($cleaned_content);
        $content_stems = self::stem_words($content_keywords);

        $target_posts = self::get_target_posts($current_post_id);

        if (empty($target_posts)) {
            return [];
        }

        $suggestions = [];

        foreach ($target_posts as $post) {
            $anchor_text = self::find_best_anchor(
                $cleaned_content,
                $content_phrases,
                $content_keywords,
                $content_stems,
                $post
            );

            if ($anchor_text !== null) {
                if (!self::is_anchor_valid_for_content($anchor_text['anchor'], $cleaned_content)) {
                    continue;
                }
                if ($ignore_numbers && preg_match('/\d/', (string) $anchor_text['anchor'])) {
                    continue;
                }
                if (self::is_anchor_ignored($anchor_text['anchor'], $ignore_words)) {
                    continue;
                }
                $candidate = [
                    'title' => $post->post_title,
                    'url' => get_permalink($post->ID),
                    'suggested_anchor' => $anchor_text['anchor'],
                    'relevance' => $anchor_text['score'],
                    'anchor_in_content' => $anchor_text['in_content'],
                ];

                $anchor_key = self::build_anchor_dedupe_key((string) $candidate['suggested_anchor']);
                if ($anchor_key === '') {
                    continue;
                }

                if (
                    !isset($suggestions[$anchor_key]) ||
                    (int) ($candidate['relevance'] ?? 0) > (int) ($suggestions[$anchor_key]['relevance'] ?? 0)
                ) {
                    $suggestions[$anchor_key] = $candidate;
                }
            }
        }

        $suggestions = array_values($suggestions);

        if (count($suggestions) > 1) {
            usort($suggestions, function($a, $b) {
                return ($b['relevance'] ?? 0) <=> ($a['relevance'] ?? 0);
            });
        }

        return array_slice($suggestions, 0, $max_suggestions);
    }

    /**
     * Get target posts for linking suggestions
     *
     * @param int $exclude_post_id Post ID to exclude.
     * @return array
     */
    private static function get_target_posts(int $exclude_post_id): array {
        $settings = self::get_internal_linking_settings();
        $post_types = $settings['link_post_types'] ?? ($settings['post_types'] ?? ['post', 'page']);
        $post_statuses = self::parse_post_statuses($settings['post_statuses'] ?? 'publish');

        if (!is_array($post_types)) {
            $post_types = (array) $post_types;
        }

        $post_types = array_values(array_filter($post_types));
        if (empty($post_types)) {
            $post_types = ['post', 'page'];
        }

        $args = [
            'post_type'      => $post_types,
            'post_status'    => $post_statuses,
            'posts_per_page' => self::MAX_POSTS_TO_SCAN,
            'post__not_in'   => [$exclude_post_id],
            'orderby'        => 'modified',
            'order'          => 'DESC',
            'no_found_rows'  => true,
            'fields'         => 'ids',
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ];

        $args['post_type'] = apply_filters('prorank_seo_internal_linking_post_types', $args['post_type']);
        if (!is_array($args['post_type'])) {
            $args['post_type'] = array_filter((array) $args['post_type']);
        }
        if (empty($args['post_type'])) {
            $args['post_type'] = ['post', 'page'];
        }

        $exclude_categories = array_filter(array_map(
            'intval',
            (array) ($settings['exclude_categories'] ?? [])
        ));
        if (!empty($exclude_categories)) {
            $args['category__not_in'] = $exclude_categories;
        }

        $max_age_date_query = self::build_date_query_from_max_post_age((string) ($settings['max_post_age'] ?? 'no_limit'));
        if (!empty($max_age_date_query)) {
            $args['date_query'] = [$max_age_date_query];
        }

        $query = new WP_Query($args);

        if (!$query->have_posts()) {
            return [];
        }

        $posts = [];
        foreach ($query->posts as $post_id) {
            $post = get_post($post_id);
            if ($post) {
                $posts[] = $post;
            }
        }

        return $posts;
    }

    /**
     * Normalize ignore words into an array.
     *
     * @param string $ignore_words Raw ignore words.
     * @return array
     */
    private static function parse_ignore_words(string $ignore_words): array {
        if ($ignore_words === '') {
            return [];
        }

        $parts = array_map('trim', explode(',', $ignore_words));
        $parts = array_filter($parts);
        $parts = array_map('strtolower', $parts);

        return array_values(array_unique($parts));
    }

    /**
     * Parse newline-separated ignored words text.
     *
     * @param string $ignored_words_text Newline-separated ignored words.
     * @return array
     */
    private static function parse_ignored_words_text(string $ignored_words_text): array {
        if ($ignored_words_text === '') {
            return [];
        }

        $parts = preg_split('/\r\n|\r|\n/', $ignored_words_text);
        if (!is_array($parts)) {
            return [];
        }

        $parts = array_map('trim', $parts);
        $parts = array_filter($parts, static function($value): bool {
            return $value !== '';
        });
        $parts = array_map('strtolower', $parts);

        return array_values(array_unique($parts));
    }

    /**
     * Parse post statuses from settings value.
     *
     * @param mixed $raw_statuses Post statuses setting.
     * @return array
     */
    private static function parse_post_statuses($raw_statuses): array {
        $statuses = [];
        if (is_array($raw_statuses)) {
            $statuses = $raw_statuses;
        } elseif (is_string($raw_statuses)) {
            $statuses = array_map('trim', explode(',', $raw_statuses));
        }

        $statuses = array_values(array_unique(array_filter(array_map('sanitize_key', $statuses))));
        if (empty($statuses)) {
            $statuses = ['publish'];
        }

        return $statuses;
    }

    /**
     * Convert max-post-age setting into WP_Query date_query arguments.
     *
     * @param string $max_post_age Max post age setting.
     * @return array|null
     */
    private static function build_date_query_from_max_post_age(string $max_post_age): ?array {
        $max_post_age = trim($max_post_age);
        if ($max_post_age === '' || $max_post_age === 'no_limit') {
            return null;
        }

        if (!preg_match('/^(\d+)\s*(day|days|month|months|year|years)$/i', $max_post_age, $matches)) {
            return null;
        }

        $value = max(1, (int) $matches[1]);
        $unit = strtolower($matches[2]);
        if (str_ends_with($unit, 's')) {
            $unit = substr($unit, 0, -1);
        }

        return [
            'after' => sprintf('%d %s ago', $value, $unit),
            'inclusive' => true,
        ];
    }

    /**
     * Check if an anchor is excluded by ignore words.
     *
     * @param string $anchor       Anchor text.
     * @param array  $ignore_words Ignore words list.
     * @return bool
     */
    private static function is_anchor_ignored(string $anchor, array $ignore_words): bool {
        if (empty($ignore_words)) {
            return false;
        }

        $anchor_lower = strtolower($anchor);
        foreach ($ignore_words as $ignore) {
            if ($ignore === '') {
                continue;
            }
            if (strpos($anchor_lower, $ignore) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Determine max suggestion count from settings.
     *
     * @param array $settings Settings array.
     * @return int
     */
    private static function get_max_suggestions(array $settings): int {
        $max_links = (int) ($settings['max_links_per_post'] ?? self::MAX_SUGGESTIONS);
        if ($max_links <= 0) {
            $max_links = self::MAX_SUGGESTIONS;
        }

        return min(self::MAX_SUGGESTIONS, $max_links);
    }

    /**
     * Find anchor text in content
     *
     * @param string $content    Content to search in.
     * @param string $post_title Target post title.
     * @param int    $post_id    Target post ID.
     * @return string|null
     */
    private static function find_anchor_text(
        string $content,
        string $post_title,
        int $post_id
    ): ?string {
        if (stripos($content, $post_title) !== false) {
            return self::extract_anchor_context($content, $post_title);
        }

        $title_words = self::get_significant_words($post_title);

        foreach ($title_words as $word) {
            if (strlen($word) >= self::MIN_ANCHOR_LENGTH && stripos($content, $word) !== false) {
                $potential_anchor = self::extract_anchor_context($content, $word);
                if ($potential_anchor !== null) {
                    return $potential_anchor;
                }
            }
        }

        $focus_keyword = FocusKeywords::get_primary($post_id);
        if (empty($focus_keyword)) {
            $focus_keyword = get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
        }

        if (!empty($focus_keyword) && stripos($content, $focus_keyword) !== false) {
            return self::extract_anchor_context($content, $focus_keyword);
        }

        return null;
    }

    /**
     * Find the best anchor candidate for a target post
     *
     * @param string  $content          Current post content.
     * @param array   $phrases          Extracted phrases from content.
     * @param array   $content_keywords Significant words from content.
     * @param WP_Post $target_post      Target post object.
     * @return array|null Anchor data.
     */
    private static function find_best_anchor(
        string $content,
        array $phrases,
        array $content_keywords,
        array $content_stems,
        \WP_Post $target_post
    ): ?array {
        $title_words = self::get_significant_words($target_post->post_title);
        $target_keywords = $title_words;

        $focus_keyword = FocusKeywords::get_primary($target_post->ID);
        if (empty($focus_keyword)) {
            $focus_keyword = get_post_meta($target_post->ID, '_yoast_wpseo_focuskw', true);
        }
        if (!empty($focus_keyword)) {
            $target_keywords = array_merge(
                $target_keywords,
                self::get_significant_words($focus_keyword)
            );
        }

        $slug = $target_post->post_name ?? '';
        if (!empty($slug)) {
            $target_keywords = array_merge(
                $target_keywords,
                self::get_significant_words(str_replace('-', ' ', $slug))
            );
        }

        $target_keywords = array_values(array_unique($target_keywords));
        $target_stems = self::stem_words($target_keywords);
        $candidate_anchors = self::get_anchor_candidates($target_post);

        $best = null;

        foreach ($candidate_anchors as $candidate) {
            $candidate = trim($candidate);
            if ($candidate === '') {
                continue;
            }

            if (self::is_weak_anchor($candidate)) {
                continue;
            }

            $candidate_length = self::get_text_length($candidate);
            if ($candidate_length < self::MIN_ANCHOR_LENGTH || $candidate_length > self::MAX_ANCHOR_LENGTH) {
                continue;
            }

            $in_content = stripos($content, $candidate) !== false;
            if (!$in_content) {
                continue;
            }
            $candidate_words = self::get_significant_words($candidate);
            $candidate_stems = self::stem_words($candidate_words);
            $common_words = array_intersect($candidate_stems, $content_stems);

            $score = 0;
            if ($in_content) {
                $score += 70;
                $score += min(20, (int) ($candidate_length / 2));
            }

            $score += count($common_words) * 6;

            if ($score > 0 && ($best === null || $score > $best['score'])) {
                $best = [
                    'anchor' => $candidate,
                    'score' => $score,
                    'in_content' => $in_content,
                ];
            }
        }

        foreach ($phrases as $phrase_data) {
            $phrase = $phrase_data['phrase'] ?? '';
            if ($phrase === '') {
                continue;
            }

            if (self::is_weak_anchor($phrase)) {
                continue;
            }

            $phrase_words = self::get_significant_words($phrase);
            $phrase_stems = self::stem_words($phrase_words);
            $common_with_target = array_intersect($phrase_stems, $target_stems);

            if (empty($common_with_target)) {
                continue;
            }

            $score = 30 + (int) ($phrase_data['count'] ?? 1) * 2;
            $score += count($common_with_target) * 12;
            $score += min(10, count($phrase_words) * 2);

            if ($best === null || $score > $best['score']) {
                $best = [
                    'anchor' => $phrase,
                    'score' => $score,
                    'in_content' => true,
                ];
            }
        }

        if ($best && stripos($content, $best['anchor']) === false) {
            $context_anchor = self::extract_anchor_context($content, $best['anchor']);
            if (!empty($context_anchor) && !self::is_weak_anchor($context_anchor)) {
                $best['anchor'] = $context_anchor;
            }
        }

        return $best;
    }

    /**
     * Build candidate anchors for a target post
     *
     * @param WP_Post $post Post object.
     * @return array
     */
    private static function get_anchor_candidates(\WP_Post $post): array {
        $candidates = [];

        $candidates[] = $post->post_title;

        $focus_keyword = FocusKeywords::get_primary($post->ID);
        if (empty($focus_keyword)) {
            $focus_keyword = get_post_meta($post->ID, '_yoast_wpseo_focuskw', true);
        }
        if (!empty($focus_keyword)) {
            $candidates[] = $focus_keyword;
        }

        $slug = $post->post_name ?? '';
        if (!empty($slug)) {
            $candidates[] = str_replace('-', ' ', $slug);
        }

        $candidates = array_filter(array_map('trim', $candidates));
        return array_values(array_unique($candidates));
    }

    /**
     * Extract phrases from content for matching
     *
     * @param string $content Post content.
     * @return array
     */
    private static function extract_phrases(string $content): array {
        $text = self::normalize_text($content);
        if ($text === '') {
            return [];
        }

        $words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        if (empty($words)) {
            return [];
        }

        $phrases = [];
        $count = count($words);
        $min_words = 2;
        $max_words = 5;
        for ($i = 0; $i < $count; $i++) {
            for ($len = $min_words; $len <= $max_words; $len++) {
                if ($i + $len > $count) {
                    break;
                }
                $phrase = implode(' ', array_slice($words, $i, $len));
                if (self::is_weak_anchor($phrase)) {
                    continue;
                }
                $phrases[$phrase] = ($phrases[$phrase] ?? 0) + 1;
            }
        }

        arsort($phrases);

        $result = [];
        foreach ($phrases as $phrase => $count) {
            if (self::is_weak_anchor($phrase)) {
                continue;
            }
            $result[] = [
                'phrase' => $phrase,
                'count' => $count,
            ];
            if (count($result) >= self::MAX_PHRASES) {
                break;
            }
        }

        return $result;
    }

    /**
     * Extract significant words from content
     *
     * @param string $content Content to analyze.
     * @return array
     */
    private static function get_significant_words_from_content(string $content): array {
        $text = self::normalize_text($content);
        if ($text === '') {
            return [];
        }

        $words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        $stop_words = self::get_stop_words();
        $significant_words = [];

        foreach ($words as $word) {
            $word = trim($word, '.,!?;:"\'-');
            if ($word === '' || in_array($word, $stop_words, true)) {
                continue;
            }
            if (preg_match('/^\d+$/', $word)) {
                continue;
            }
            if (self::get_text_length($word) < self::MIN_ANCHOR_LENGTH) {
                continue;
            }
            $significant_words[] = $word;
        }

        return array_values(array_unique($significant_words));
    }

    /**
     * Normalize text for matching
     *
     * @param string $text Raw text.
     * @return string
     */
    private static function normalize_text(string $text): string {
        $text = wp_strip_all_tags($text);
        $text = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $text);
        $text = trim($text);

        if (function_exists('mb_strtolower')) {
            return mb_strtolower($text);
        }

        return strtolower($text);
    }

    /**
     * Build a stable key for anchor deduplication.
     *
     * @param string $anchor Anchor text.
     * @return string
     */
    private static function build_anchor_dedupe_key(string $anchor): string {
        $normalized = self::normalize_text($anchor);
        $normalized = preg_replace('/\s+/u', ' ', $normalized ?? '');
        return trim((string) $normalized);
    }

    /**
     * Get stop words list
     *
     * @return array
     */
    private static function get_stop_words(): array {
        return [
            'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for',
            'from', 'has', 'he', 'in', 'is', 'it', 'its', 'of', 'on',
            'that', 'the', 'to', 'was', 'will', 'with', 'this', 'these',
            'those', 'your', 'you', 'we', 'our', 'they', 'them', 'their',
            'or', 'but', 'not', 'if', 'then', 'than', 'also',
            'how', 'when', 'why', 'what', 'where', 'who', 'which', 'whom', 'whose',
            'can', 'could', 'should', 'would', 'may', 'might', 'do', 'does', 'did',
            'doing', 'done', 'make', 'makes', 'made', 'get', 'gets', 'got',
        ];
    }

    /**
     * Validate anchor text against content and weak-anchor rules.
     *
     * @param string $anchor  Anchor text.
     * @param string $content Current post content (plain or raw).
     * @return bool
     */
    public static function is_anchor_valid_for_content(string $anchor, string $content): bool {
        if ($anchor === '') {
            return false;
        }

        if (self::is_weak_anchor($anchor)) {
            return false;
        }

        $normalized_anchor = self::normalize_text($anchor);
        if ($normalized_anchor === '') {
            return false;
        }

        $normalized_content = self::normalize_text($content);
        if ($normalized_content === '') {
            return false;
        }

        return strpos($normalized_content, $normalized_anchor) !== false;
    }

    /**
     * Check if anchor text is too weak or generic
     *
     * @param string $anchor Anchor text.
     * @return bool
     */
    private static function is_weak_anchor(string $anchor): bool {
        $normalized = self::normalize_text($anchor);
        if ($normalized === '') {
            return true;
        }

        $words = preg_split('/\s+/', $normalized, -1, PREG_SPLIT_NO_EMPTY);
        if (empty($words)) {
            return true;
        }

        $stop_words = self::get_stop_words();
        $weak_words = self::get_weak_words();
        $non_weak = 0;

        foreach ($words as $word) {
            if (preg_match('/^\d+$/', $word)) {
                continue;
            }
            if (in_array($word, $stop_words, true) || in_array($word, $weak_words, true)) {
                continue;
            }
            $non_weak++;
        }

        if ($non_weak === 0) {
            return true;
        }

        if (count($words) >= 2 && $non_weak < 2) {
            return true;
        }

        if (count($words) === 1) {
            $word = $words[0];
            if (self::get_text_length($word) < 4) {
                return true;
            }
            if (in_array($word, $stop_words, true) || in_array($word, $weak_words, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Extra weak words beyond stop words
     *
     * @return array
     */
    private static function get_weak_words(): array {
        return [
            'about', 'after', 'before', 'during', 'because', 'into', 'over', 'under',
            'between', 'within', 'without', 'very', 'more', 'most', 'less',
        ];
    }

    /**
     * Get text length with multibyte support
     *
     * @param string $text Text to measure.
     * @return int
     */
    private static function get_text_length(string $text): int {
        if (function_exists('mb_strlen')) {
            return mb_strlen($text);
        }

        return strlen($text);
    }

    /**
     * Stem words for lightweight matching
     *
     * @param array $words Words to stem.
     * @return array
     */
    private static function stem_words(array $words): array {
        $stems = [];
        foreach ($words as $word) {
            $stem = self::stem_word($word);
            if ($stem !== '') {
                $stems[] = $stem;
            }
        }

        return array_values(array_unique($stems));
    }

    /**
     * Basic stemmer for English-like languages
     *
     * @param string $word Word to stem.
     * @return string
     */
    private static function stem_word(string $word): string {
        $word = self::normalize_text($word);
        if ($word === '') {
            return '';
        }

        $suffixes = [
            'ingly', 'edly', 'edly', 'ings', 'ing', 'ed', 'ly',
            'ments', 'ment', 'ations', 'ation', 'izers', 'izer',
            'ities', 'ity', 'ables', 'able', 'ibles', 'ible',
            'ness', 'ers', 'er', 'est', 'es', 's',
        ];

        foreach ($suffixes as $suffix) {
            $suffix_len = strlen($suffix);
            if (strlen($word) > $suffix_len + 3 && substr($word, -$suffix_len) === $suffix) {
                return substr($word, 0, -$suffix_len);
            }
        }

        return $word;
    }

    /**
     * Extract anchor text with context
     *
     * @param string $content Content to search in.
     * @param string $phrase  Phrase to find.
     * @return string|null
     */
    private static function extract_anchor_context(string $content, string $phrase): ?string {
        $pos = stripos($content, $phrase);
        if ($pos === false) {
            return null;
        }

        $before_pos = $pos;
        $after_pos = $pos + strlen($phrase);

        while ($before_pos > 0 && !self::is_word_boundary($content[$before_pos - 1])) {
            $before_pos--;
        }

        $content_length = strlen($content);
        while ($after_pos < $content_length && !self::is_word_boundary($content[$after_pos])) {
            $after_pos++;
        }

        $anchor = substr($content, $before_pos, $after_pos - $before_pos);
        $anchor = trim($anchor);

        if (strlen($anchor) < self::MIN_ANCHOR_LENGTH || strlen($anchor) > 100) {
            return null;
        }

        return $anchor;
    }

    /**
     * Get significant words from a title
     *
     * @param string $title Post title.
     * @return array
     */
    private static function get_significant_words(string $title): array {
        $stop_words = self::get_stop_words();
        $words = preg_split('/\s+/', self::normalize_text($title));

        $significant_words = [];
        foreach ($words as $word) {
            $word = trim($word, '.,!?;:"\'-');
            if (strlen($word) >= self::MIN_ANCHOR_LENGTH && !in_array($word, $stop_words, true)) {
                $significant_words[] = $word;
            }
        }

        return $significant_words;
    }

    /**
     * Determine if a character is a word boundary
     *
     * @param string $char Character to check.
     * @return bool
     */
    private static function is_word_boundary(string $char): bool {
        return (bool) preg_match('/\s|[.,!?;:"\'()]/', $char);
    }

    /**
     * Get link density (percentage of linked words)
     *
     * @param string $content Post content.
     * @return float
     */
    public static function get_link_density(string $content): float {
        $text = wp_strip_all_tags($content);
        $word_count = str_word_count($text);
        if ($word_count === 0) {
            return 0.0;
        }

        preg_match_all('/<a\s[^>]*>(.*?)<\/a>/i', $content, $matches);
        $linked_text = implode(' ', $matches[1] ?? []);
        $linked_words = str_word_count(wp_strip_all_tags($linked_text));

        return round(($linked_words / $word_count) * 100, 2);
    }
}
