<?php
declare(strict_types=1);
// ==================================================
// Nuclear Fancy Pants Edition™ — NUCLEAR+++
// File: /home/ploi/automarketer-ai.com/public/wp-content/plugins/automarketer-ai/includes/class-seo-analyzer.php
// Description: SEO and readability analyzer — scores posts and produces actionable issues - NUCLEAR+++
// Last Edited: 2026-04-26
// ==================================================

namespace AutoMarketerAI;

defined('ABSPATH') || exit;

class SeoAnalyzer
{
    // --------------------------------------------------
    // SEO score weights — must sum to 100
    // --------------------------------------------------
    private const SEO_WEIGHTS = [
        'keyword_in_title'      => 20,
        'keyword_in_meta_desc'  => 15,
        'keyword_in_first_para' => 15,
        'keyword_in_slug'       => 10,
        'keyword_in_alt'        => 5,
        'meta_title_length'     => 10,
        'meta_desc_length'      => 10,
        'internal_links'        => 5,
        'images_with_alt'       => 5,
        'content_length'        => 5,
    ];

    // --------------------------------------------------
    // Readability score weights — must sum to 100
    // --------------------------------------------------
    private const READ_WEIGHTS = [
        'sentence_length'  => 25,
        'paragraph_length' => 20,
        'passive_voice'    => 20,
        'transition_words' => 20,
        'subheading_dist'  => 15,
    ];

    // --------------------------------------------------
    // Common English transition words
    // --------------------------------------------------
    private const TRANSITION_WORDS = [
        'additionally', 'afterward', 'also', 'although', 'as a result',
        'because', 'besides', 'but', 'consequently', 'despite', 'during',
        'finally', 'first', 'for example', 'for instance', 'furthermore',
        'hence', 'however', 'in addition', 'in conclusion', 'in contrast',
        'in fact', 'in other words', 'in short', 'in summary', 'likewise',
        'meanwhile', 'moreover', 'nevertheless', 'next', 'nonetheless',
        'on the other hand', 'otherwise', 'second', 'similarly', 'since',
        'so', 'subsequently', 'therefore', 'third', 'though', 'thus',
        'to summarize', 'ultimately', 'whereas', 'while', 'yet',
    ];

    // --------------------------------------------------
    // Passive voice auxiliaries
    // --------------------------------------------------
    private const PASSIVE_AUXILIARIES = [
        'was', 'were', 'is', 'are', 'been', 'being', 'be',
        'has been', 'have been', 'had been', 'will be', 'would be',
        'can be', 'could be', 'should be', 'may be', 'might be',
    ];

    // --------------------------------------------------
    // Analyze a post — main entry point
    // Returns [score_seo, score_readability, issues[]]
    // Writes scores and auto-extracted keyword to postmeta via SeoData
    // --------------------------------------------------
    public static function analyze(int $post_id): array
    {
        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            return ['score_seo' => 0, 'score_readability' => 0, 'issues' => []];
        }

        $seo_data = SeoData::getAll($post_id);
        $plain    = self::stripContent($post->post_content);
        $keyword  = strtolower(trim((string) ($seo_data['focus_keyword'] ?? '')));

        // --------------------------------------------------
        // Auto-extract keyword via Core if not already set
        // Zero user interaction — fires silently on save
        // --------------------------------------------------
        if ($keyword === '' && $plain !== '') {
            $extracted = self::extractKeywordFromCore($post->post_title, $plain);
            if ($extracted !== null) {
                SeoData::set($post_id, 'focus_keyword', $extracted);
                $keyword = $extracted;
            }
        }

        $meta_title = (string) ($seo_data['meta_title']       ?? '');
        $meta_desc  = (string) ($seo_data['meta_description'] ?? '');
        $post_title = $post->post_title;
        $slug       = $post->post_name;
        $word_count = self::wordCount($plain);
        $sentences  = self::splitSentences($plain);
        $paragraphs = self::splitParagraphs($post->post_content);
        $issues     = [];

        $seo_score = self::scoreSeo(
            $post_id,
            $keyword,
            $meta_title,
            $meta_desc,
            $post_title,
            $slug,
            $plain,
            $post->post_content,
            $word_count,
            $issues
        );

        $read_score = self::scoreReadability(
            $plain,
            $sentences,
            $paragraphs,
            $post->post_content,
            $word_count,
            $issues
        );

        SeoData::set($post_id, 'score_seo', $seo_score);
        SeoData::set($post_id, 'score_readability', $read_score);
        SeoData::set($post_id, 'issue_count', count($issues));
        SeoData::touchAnalyzedAt($post_id);

        // Store fix prompts in transient keyed by index -- never sent to browser
        $prompts = [];
        foreach ($issues as $i => $iss) {
            $allowed = [];
            if (($iss['fix_mode'] ?? '') === 'paragraphs' && !empty($iss['fix_prompt'])) {
                // Extract paragraph indexes from the input JSON embedded in the prompt
                if (preg_match('/Paragraphs to fix \\(JSON array[^)]*\\): (\\[.*\\])/s', $iss['fix_prompt'], $m)) {
                    $decoded = json_decode($m[1], true);
                    if (is_array($decoded)) {
                        foreach ($decoded as $entry) {
                            if (isset($entry['index'])) {
                                $allowed[] = (int) $entry['index'];
                            }
                        }
                    }
                }
            }
            $prompts[$i] = [
                'prompt'          => $iss['fix_prompt'] ?? '',
                'allowed_indexes' => $allowed,
            ];
        }
        set_transient('amai_seo_prompts_' . $post_id, $prompts, HOUR_IN_SECONDS);
        // Strip fix_prompt from issues before sending to browser
        // If fix_prompt is empty for a paragraphs-mode issue, clear fix_mode so no broken Fix button renders
        $safe_issues = [];
        foreach ($issues as $i => $iss) {
            $safe = $iss;
            if (($safe['fix_prompt'] ?? '') === '' && ($safe['fix_mode'] ?? '') !== '') {
                $safe['fix_mode'] = '';
            }
            unset($safe['fix_prompt']);
            $safe['issue_index'] = $i;
            $safe_issues[] = $safe;
        }

        return [
            'score_seo'         => $seo_score,
            'score_readability' => $read_score,
            'issues'            => $safe_issues,
        ];
    }

    // --------------------------------------------------
    // Call Core /api/seo/extract-keyword
    // Returns cleaned keyword string or null on any failure
    // --------------------------------------------------
    private static function extractKeywordFromCore(string $title, string $plain): ?string
    {
        if (!defined('AMAI_HMAC_SECRET') || AMAI_HMAC_SECRET === '') {
            return null;
        }
        if (!defined('AMAI_CORE_URL') || AMAI_CORE_URL === '') {
            return null;
        }

        $method    = 'POST';
        $path      = '/api/seo/extract-keyword';
        $url       = rtrim(AMAI_CORE_URL, '/') . $path;
        $timestamp = (string) time();
        $nonce     = bin2hex(random_bytes(16));
        $tenant    = amai_tenant_id();
        $key_id    = 'WP_PROXY';

        // Collect all focus keywords already in use to pass as avoid list
        $avoid_keywords = [];
        global $wpdb;
        $used_rows = $wpdb->get_col(
            "SELECT meta_value FROM {$wpdb->postmeta}"
            . " INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id"
            . " WHERE meta_key = '_amai_seo_focus_keyword'"
            . " AND {$wpdb->posts}.post_status = 'publish'"
            . " AND meta_value != ''"
        );
        if (is_array($used_rows)) {
            foreach ($used_rows as $kw) {
                $kw = strtolower(trim((string) $kw));
                if ($kw !== '') {
                    $avoid_keywords[] = $kw;
                }
            }
            $avoid_keywords = array_values(array_unique($avoid_keywords));
        }
        $payload   = ['title' => $title, 'content' => mb_substr($plain, 0, 1500), 'avoid' => $avoid_keywords];
        $body      = (string) json_encode($payload);
        $raw_secret = (string) get_option('amai_tenant_hmac_secret', '');
        if ($raw_secret === '') {
            return null;
        }
        $secret    = hex2bin($raw_secret);
        $body_b64  = base64_encode(hash('sha256', $body, true));
        $canonical = implode('|', [$method, $path, $timestamp, $nonce, $tenant, $key_id, $body_b64]);
        $signature = rtrim(base64_encode(hash_hmac('sha256', $canonical, $secret, true)), "\n");

        $response = wp_remote_post($url, [
            'timeout' => 20,
            'headers' => [
                'Content-Type'       => 'application/json',
                'X-Amai-Tenant'      => $tenant,
                'X-Amai-Key-Id'      => $key_id,
                'X-Amai-Timestamp'   => $timestamp,
                'X-Amai-Nonce'       => $nonce,
                'X-Amai-Body-SHA256' => $body_b64,
                'X-Amai-Signature'   => $signature,
            ],
            'body' => $body,
        ]);

        if (is_wp_error($response)) {
            error_log('[AutoMarketer AI] SeoAnalyzer::extractKeywordFromCore - wp_error: ' . $response->get_error_message());
            return null;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        $data = json_decode(wp_remote_retrieve_body($response), true);

        if ($code !== 200 || !is_array($data) || empty($data['keyword'])) {
            error_log('[AutoMarketer AI] SeoAnalyzer::extractKeywordFromCore - bad response ' . $code);
            return null;
        }

        $keyword = strtolower(trim((string) $data['keyword']));
        if (mb_strlen($keyword) > 100) { return null; }
        return $keyword !== '' ? $keyword : null;
    }

    // --------------------------------------------------
    // SEO scoring — returns 0-100 int
    // --------------------------------------------------
    private static function scoreSeo(
        int $post_id,
        string $keyword,
        string $meta_title,
        string $meta_desc,
        string $post_title,
        string $slug,
        string $plain,
        string $raw_content,
        int $word_count,
        array &$issues
    ): int {
        $points      = 0;
        $has_keyword = $keyword !== '';

        // keyword in title
        if ($has_keyword && mb_stripos($post_title, $keyword) !== false) {
            $points += self::SEO_WEIGHTS['keyword_in_title'];
        } else {
            $issues[] = self::issue(
                'post_title',
                $has_keyword ? 'error' : 'warning',
                $has_keyword
                    ? 'Your focus keyword is not in the post title.'
                    : 'No focus keyword could be determined. Add content to enable full SEO analysis.',
                $has_keyword
                    ? 'Rewrite this post title to naturally include the focus keyword "' . $keyword . '". Current title: "' . $post_title . '". Keep it under 60 characters and make it compelling. Use hyphens only, never em-dashes.'
                    : '',
                'direct'
            );
        }

        // keyword in meta description
        if ($has_keyword && mb_stripos($meta_desc, $keyword) !== false) {
            $points += self::SEO_WEIGHTS['keyword_in_meta_desc'];
        } elseif ($has_keyword) {
            $issues[] = self::issue(
                'meta_description',
                'error',
                'Your focus keyword is not in the meta description.',
                'Rewrite this meta description to include the focus keyword "' . $keyword . '" naturally. '
                    . ($meta_desc !== '' ? 'Current: "' . $meta_desc . '". ' : 'The meta description is currently empty -- write one from scratch using the post title and content as context. Post title: "' . $post_title . '". ')
                    . 'Output ONLY the description text, no preamble, no quotes, nothing else. Between 140 and 160 characters. Write it to earn the click. Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        }

        // keyword in first sentence
        $first_para     = self::firstParagraphFromRaw($raw_content);
        $first_sentence = self::firstSentenceFromParagraph($first_para);
        if ($has_keyword && mb_stripos($first_sentence, $keyword) !== false) {
            $points += self::SEO_WEIGHTS['keyword_in_first_para'];
        } elseif ($has_keyword) {
            $issues[] = self::issue(
                'post_first_para',
                'warning',
                'Your focus keyword does not appear in the first sentence of your opening paragraph.',
                'Rewrite only the first sentence of this paragraph so it naturally includes the focus keyword "' . $keyword . '". Keep the same tone and meaning. Do not add new information. Return only the rewritten paragraph text with no HTML tags, no preamble, no explanation. Use hyphens only, never em-dashes. Paragraph: "' . mb_substr($first_para, 0, 400) . '"'
            ,
                'direct'
            );
        }

        // keyword density -- too few or keyword stuffing
        if ($has_keyword && $word_count >= 100) {
            $kw_lower    = mb_strtolower($keyword);
            $plain_lower = mb_strtolower($plain);
            $kw_len      = mb_strlen($kw_lower);
            $kw_count    = 0;
            $offset      = 0;
            while (($pos = mb_strpos($plain_lower, $kw_lower, $offset)) !== false) {
                $kw_count++;
                $offset = $pos + $kw_len;
            }
            $density = $word_count > 0 ? ($kw_count / $word_count) * 100 : 0.0;
            if ($density > 2.5) {
                $issues[] = self::issue(
                    'post_content',
                    'warning',
                    'Focus keyword appears too often (' . round($density, 1) . '% density). Aim for 0.5-2.5%.',
                    'The focus keyword "' . $keyword . '" is overused at ' . round($density, 1) . '% density (' . $kw_count . ' occurrences in ' . $word_count . ' words). Reduce it to appear naturally 2-4 times per 300 words by replacing some occurrences with synonyms or related phrases. At the same time, if the post is under 1000 words, expand it to between 1000 and 1500 words by adding depth, examples, and useful information. Do not change the post intent or remove existing content. Return the complete post as valid HTML using only <p>, <h2>, <h3>, and <ul>/<li> tags. Every paragraph in <p> tags. No plain text outside tags. No other HTML tags. No preamble, no explanation. Use hyphens only, never em-dashes. Current post: "' . mb_substr($raw_content, 0, 15000) . '"',
                    'direct'
                );
            } elseif ($density < 0.5) {
                $issues[] = self::issue(
                    'post_content',
                    'suggestion',
                    'Focus keyword appears rarely (' . round($density, 1) . '% density). Try to use it a few more times naturally.',
                    'TASK: Add the focus keyword to paragraphs that do not already contain it. This is a SURGICAL edit -- do not rewrite, restructure, or change any existing text. Focus keyword: "' . $keyword . '". It currently appears ' . $kw_count . ' time' . ($kw_count === 1 ? '' : 's') . ' in ' . $word_count . ' words. You need to add it to ' . max(1, max(7, (int) ceil($word_count * 0.005)) - $kw_count) . ' more paragraph' . (max(1, max(7, (int) ceil($word_count * 0.005)) - $kw_count) === 1 ? '' : 's') . '. RULES: (1) Find paragraphs that do not contain the keyword. (2) In each such paragraph, replace one phrase or noun with the keyword where it fits naturally -- change as few words as possible. (3) Do not use the keyword more than once per paragraph. (4) Do not add transition words. (5) Do not change any sentence that already contains the keyword. (6) Do not add, remove, or reorder any paragraphs or headings. (7) Return the COMPLETE post as valid HTML -- identical to the input except for the minimal keyword insertions. Use only <p>, <h2>, <h3>, and <ul>/<li> tags. No plain text outside tags. No preamble, no explanation. Use hyphens only, never em-dashes. Current post: "' . mb_substr($raw_content, 0, 15000) . '"',
                    'direct'
                );
            }
        }
        // keyword in slug
        if ($has_keyword) {
            $keyword_slug = sanitize_title($keyword);
            if (mb_stripos($slug, $keyword_slug) !== false) {
                $points += self::SEO_WEIGHTS['keyword_in_slug'];
            } else {
                $issues[] = self::issue(
                    'post_slug',
                    'suggestion',
                    'Your URL slug does not contain the focus keyword.',
                    'Suggest a URL slug that includes the focus keyword "' . $keyword . '". Current slug: "' . $slug . '". Keep it short, lowercase, hyphen-separated, no stop words. Use hyphens only, never em-dashes.'
                ,
                    'direct'
                );
            }
        }
        // keyword in image alt text -- award points silently, no issue shown
        if (self::keywordInImageAlts($raw_content, $keyword, $post_id)) {
            $points += self::SEO_WEIGHTS['keyword_in_alt'];
        }
        // meta title length
        $title_len = mb_strlen($meta_title);
        if ($title_len >= 45 && $title_len <= 60) {
            $points += self::SEO_WEIGHTS['meta_title_length'];
        } elseif ($title_len === 0) {
            $issues[] = self::issue(
                'meta_title',
                'error',
                'No SEO title set.',
                'Write an SEO title for this post' . ($has_keyword ? ' that includes the focus keyword "' . $keyword . '"' : '') . '. Keep it between 45-60 characters. Post title for reference: "' . $post_title . '". Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        } elseif ($title_len < 45) {
            $issues[] = self::issue(
                'meta_title',
                'warning',
                'SEO title is too short (' . $title_len . ' characters). Aim for 45-60.',
                'Expand this SEO title to between 45-60 characters' . ($has_keyword ? ', keeping the focus keyword "' . $keyword . '"' : '') . '. Current title: "' . $meta_title . '". Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        } else {
            $issues[] = self::issue(
                'meta_title',
                'warning',
                'SEO title is too long (' . $title_len . ' characters). Keep it under 60.',
                'Shorten this SEO title to under 60 characters without losing its meaning' . ($has_keyword ? ' or the keyword "' . $keyword . '"' : '') . '. Current title: "' . $meta_title . '". Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        }

        // keyword position in meta title — should appear in first half
        if ($has_keyword && $title_len > 0) {
            $keyword_pos = mb_stripos($meta_title, $keyword);
            if ($keyword_pos !== false && $keyword_pos > ($title_len / 2)) {
                $issues[] = self::issue(
                    'meta_title',
                    'suggestion',
                    'Your focus keyword appears late in the SEO title. Move it closer to the beginning.',
                    'Rewrite this SEO title so the focus keyword "' . $keyword . '" appears in the first half. Keep it between 45-60 characters and compelling. Current title: "' . $meta_title . '". Use hyphens only, never em-dashes.'
                ,
                    'direct'
                );
            }
        }

        // meta description length
        // Detect AI reasoning garbage in meta_description and treat as empty
        $ai_garbage_phrases = ['please provide', 'you\'d like', 'in order to', 'i need', 'could you', 'to proceed', 'let me know', 'please share', 'i notice', "since there's no", 'you provided is empty', 'to complete this task', 'the original', 'i\'ll need'];
        foreach ($ai_garbage_phrases as $phrase) {
            if (mb_stripos($meta_desc, $phrase) !== false) {
                $meta_desc = '';
                SeoData::set($post_id, 'meta_description', '');
                break;
            }
        }
        $desc_len = mb_strlen($meta_desc);
        if ($desc_len >= 140 && $desc_len <= 175) {
            $points += self::SEO_WEIGHTS['meta_desc_length'];
        } elseif ($desc_len === 0) {
            $content_ctx = mb_substr(wp_strip_all_tags($raw_content), 0, 1500);
            $issues[] = self::issue(
                'meta_description',
                'error',
                'No meta description set.',
                'Write a meta description for the blog post below. Output ONLY the description text -- no preamble, no explanation, no quotes around it, nothing else. It must be between 140 and 160 characters (count every character including spaces)'
                    . ($has_keyword ? ', and must include the focus keyword "' . $keyword . '"' : '')
                    . '. Use hyphens only, never em-dashes. Post title: "' . esc_attr($post_title) . '". Post content: ' . $content_ctx
            ,
                'direct'
            );
        } elseif ($desc_len < 140) {
            $issues[] = self::issue(
                'meta_description',
                'warning',
                'Meta description is too short (' . $desc_len . ' characters). Aim for 140-160.',
                'Rewrite this meta description so it is between 140 and 160 characters long (count every character including spaces). Aim for at least 140 characters. Current text (' . $desc_len . ' chars): "' . $meta_desc . '". Expand it naturally, keep the meaning, use hyphens only, never em-dashes. Return only the description text, nothing else.'
            ,
                'direct'
            );
        } elseif ($desc_len > 170) {
            $issues[] = self::issue(
                'meta_description',
                'warning',
                'Meta description is too long (' . $desc_len . ' characters). Aim for 140-170.',
                'Shorten this meta description to under 160 characters. Current: "' . $meta_desc . '". Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        }

        // internal links
        $internal_links = self::countInternalLinks($raw_content, $post_id);
        $published_count = (int) wp_count_posts('post')->publish;
        // Subtract 1 if current post is already published so we don't count it
        $post_obj = get_post($post_id);
        if ($post_obj instanceof \WP_Post && $post_obj->post_status === 'publish') {
            $published_count = max(0, $published_count - 1);
        }
        $enough_posts = $published_count >= 10;
        if ($internal_links >= 2) {
            $points += self::SEO_WEIGHTS['internal_links'];
        } elseif ($internal_links === 1) {
            $points += (int) round(self::SEO_WEIGHTS['internal_links'] * 0.5);
            if ($enough_posts) {
                $issues[] = self::issue(
                    'internal_links',
                    'suggestion',
                    'Only one internal link found. Add at least one more.',
                    'inject_internal',
                    'inject_internal'
                );
            }
        } else {
            if ($enough_posts) {
                $issues[] = self::issue(
                    'internal_links',
                    'warning',
                    'No internal links found. Link to at least 2 related posts or pages.',
                    'inject_internal',
                    'inject_internal'
                );
            }
        }

        // outbound links
        if (!self::hasOutboundLinks($raw_content)) {
            $issues[] = self::issue(
                'outbound_links',
                'suggestion',
                'No outbound links found. Link to at least one authoritative external source.',
                'inject'
            );
        }

        // broken outbound links (cached — no live HTTP calls here)
        $broken_count = \AutoMarketerAI\LinkChecker::getBrokenLinkCount($post_id);
        if ($broken_count > 0) {
            $lc_url   = admin_url('admin.php?page=automarketer-ai-link-checker');
            $issues[] = self::issue(
                'broken_links',
                'warning',
                $broken_count . ' broken outbound ' . ($broken_count === 1 ? 'link' : 'links') . ' detected. <a href="' . esc_url($lc_url) . '" target="_blank">Fix in Link Checker</a>',
                ''
            );
        }

        // images with alt text
        $img_score = self::imageAltScore($raw_content);
        $points   += (int) round(self::SEO_WEIGHTS['images_with_alt'] * $img_score);
        if ($img_score < 1.0) {
            $issues[] = self::issue(
                'focus_keyword',
                'warning',
                'One or more images are missing alt text.',
                ''
            );
        }

        // content length -- target 1000-1500 words
        if ($word_count >= 1000 && $word_count <= 1500) {
            $points += self::SEO_WEIGHTS['content_length'];
        } elseif ($word_count > 1500) {
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                'Content is a bit long (' . $word_count . ' words). Aim for 1000-1500 words.',
                'This post is ' . $word_count . ' words, which is longer than the 1000-1500 word target. Trim it to between 1000 and 1500 words by removing redundant sentences, repetitive examples, and filler content. Preserve all key points, headings, and structure. Return the complete trimmed post as valid HTML using only <p>, <h2>, <h3>, and <ul>/<li> tags. Every paragraph in <p> tags. No plain text outside tags. No other HTML tags. No preamble, no explanation. Use hyphens only, never em-dashes. Current post: "' . mb_substr($raw_content, 0, 15000) . '"',
                'direct'
            );
        } else {
            $issues[] = self::issue(
                'post_content',
                'warning',
                'Content is too short (' . $word_count . ' words). Aim for 1000-1500 words.',
                'This post is only ' . $word_count . ' words. Expand it to between 1000 and 1500 words by adding more depth, examples, tips, and value for the reader. Preserve all existing content and structure exactly -- only add to it, never remove or rewrite existing text. Add new <h2> sections if needed to organise the added content. Return the complete expanded post as valid HTML using only <p>, <h2>, <h3>, and <ul>/<li> tags. Every paragraph in <p> tags. No plain text outside tags. No other HTML tags. No preamble, no explanation. Use hyphens only, never em-dashes. Current post: "' . mb_substr($raw_content, 0, 15000) . '"',
                'direct'
            );
        }

        // --------------------------------------------------
        // Tag count check - warn below 5 or above 10
        // --------------------------------------------------
        $tags      = wp_get_post_tags($post_id);
        $tag_count = is_array($tags) ? count($tags) : 0;
        $excerpt   = mb_substr($plain, 0, 1500);
        if ($tag_count < 5) {
            $issues[] = self::issue(
                'post_tags',
                'warning',
                'This post has ' . $tag_count . ' tag' . ($tag_count === 1 ? '' : 's') . '. Add at least 5 to improve content discoverability.',
                'Generate exactly 6 relevant tags for a blog post with this title: "' . $post_title . '". Content excerpt: "' . $excerpt . '". Return only a comma-separated list of tags, all lowercase, no hashtags, no numbers, no explanation. Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        } elseif ($tag_count > 10) {
            $issues[] = self::issue(
                'post_tags',
                'warning',
                'This post has ' . $tag_count . ' tags. Trim to 5-10 to avoid tag stuffing.',
                'Generate exactly 6 relevant tags for a blog post with this title: "' . $post_title . '". Content excerpt: "' . $excerpt . '". Return only a comma-separated list of tags, all lowercase, no hashtags, no numbers, no explanation. Use hyphens only, never em-dashes.'
            ,
                'direct'
            );
        }


        // missing H2 at top -- first content block should be an H2
        if ($word_count >= 100 && !self::hasLeadingH2($raw_content)) {
            $issues[] = self::issue(
                'post_content_prepend',
                'suggestion',
                'Your post does not start with an H2 heading. Add one to introduce the first section.',
                'Write a single H2 heading that introduces this post. The H2 should be descriptive, naturally include the focus keyword if possible, and be under 70 characters. Return ONLY the H2 tag -- nothing else. Example: <h2>Your Heading Here</h2>. No preamble, no explanation. Use hyphens only, never em-dashes. Post title for context: "' . esc_attr($post_title) . '"'
            ,
                'prepend'
            );
        }

        // section length -- warn if any H2/H3 section is under 150 words
        $thin = self::thinSections($raw_content, 150);
        if (!empty($thin)) {
            $label = count($thin) === 1
                ? '"' . esc_html($thin[0]['heading']) . '"'
                : count($thin) . ' sections';
            // Collect only the paragraphs from thin sections for surgical patching
            $thin_paras = [];
            foreach ($thin as $ts) {
                foreach (($ts['paragraphs'] ?? []) as $tp) {
                    $thin_paras[$tp['index']] = $tp['html'];
                }
            }
            ksort($thin_paras);
            // Re-index for paragraphFixPrompt (preserving global indexes)
            $para_args = [];
            foreach ($thin_paras as $global_idx => $html) {
                $para_args[$global_idx] = $html;
            }
            // Build per-section context blocks -- heading + its own paragraphs only, no full post
            $section_blocks = [];
            $all_thin_indexed = [];
            foreach ($thin as $ts) {
                $block = 'Section heading: "' . $ts['heading'] . '". Paragraphs in this section:';
                foreach (($ts['paragraphs'] ?? []) as $tp) {
                    $block .= ' [index=' . $tp['index'] . '] ' . wp_strip_all_tags($tp['html']);
                    $all_thin_indexed[] = ['index' => $tp['index'], 'html' => $tp['html']];
                }
                $section_blocks[] = $block;
            }
            $sections_text = implode(' | ', $section_blocks);
            $thin_json     = json_encode($all_thin_indexed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            $fix_prompt = !empty($all_thin_indexed)
                ? 'You are expanding short blog post sections to add substance. '
                    . 'Each section needs more detail, but every sentence you write MUST be under 18 words. '
                    . 'Write short, clear, direct sentences. No long compound sentences. No run-ons. '
                    . 'Add specific practical detail that fits the section heading topic. '
                    . 'Do not add generic filler or padding. Do not repeat what is already in the paragraph. '
                    . 'Target: expand each paragraph to at least 150 words of tight, useful content. '
                    . 'Business context: ' . mb_substr($sections_text, 0, 4000) . ' '
                    . 'RULES: (1) Return ONLY a valid JSON array. '
                    . '(2) Each object must have "index" (integer, matching input) and "fixed" (expanded paragraph as valid HTML with <p> tags). '
                    . '(3) One object per paragraph index -- do not merge or split paragraphs. '
                    . '(4) Every sentence in your output must be under 18 words. '
                    . '(5) No preamble, no explanation, no markdown fences -- raw JSON array only. '
                    . '(6) Use hyphens only, never em-dashes. '
                    . 'Paragraphs to fix (JSON array of {index, html} objects): ' . mb_substr($thin_json, 0, 3000)
                : '';
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                'Section ' . $label . ' has fewer than 150 words. Add more detail to give it substance.',
                $fix_prompt,
                'paragraphs'
            );
        }
        // too many headings -- more than 1 H2/H3 per 200 words fragments the post
        if ($word_count >= 300) {
            preg_match_all('/<h[23][^>]*>/i', $raw_content, $h_matches);
            $heading_count  = count($h_matches[0]);
            $heading_budget = (int) floor($word_count / 200);
            if ($heading_count > $heading_budget) {
                $content_preview = mb_substr($raw_content, 0, 15000);
                $heading_prompt  = 'You are editing a blog post that has too many headings for its length. '
                    . 'It has ' . $heading_count . ' headings for ' . $word_count . ' words. The target is no more than ' . $heading_budget . ' headings. '
                    . 'Consolidate short adjacent sections by merging their content into the preceding section, removing the extra heading. '
                    . 'Where appropriate, convert a short section into a bullet list within the parent section instead. '
                    . 'Preserve all factual content -- do not remove information, only restructure. '
                    . 'Keep the post title, focus keyword, and overall meaning intact. '
                    . 'RULES: (1) Return ONLY the full rewritten post content as valid HTML. '
                    . '(2) No preamble, no explanation, no markdown fences. '
                    . '(3) Use hyphens only, never em-dashes. '
                    . 'Post title: "' . esc_attr($post_title) . '". Focus keyword: "' . esc_attr($keyword) . '". '
                    . 'Post content: ' . $content_preview;
                $issues[] = self::issue(
                    'post_content',
                    'suggestion',
                    'This post has ' . $heading_count . ' headings for ' . $word_count . ' words. Consider consolidating short sections into lists or paragraphs.',
                    $heading_prompt,
                    'direct'
                );
            }
        }
        // keyword cannibalization -- warn if another published post uses the same focus keyword
        if ($keyword !== '') {
            global $wpdb;
            $conflict_id = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT post_id FROM {$wpdb->postmeta}"
                . " INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$wpdb->postmeta}.post_id"
                . " WHERE meta_key = '_amai_seo_focus_keyword'"
                . " AND meta_value = %s"
                . " AND {$wpdb->posts}.post_status = 'publish'"
                . " AND {$wpdb->postmeta}.post_id != %d"
                . " LIMIT 1",
                $keyword,
                $post_id
            ));
            if ($conflict_id > 0) {
                $conflict_title = get_the_title($conflict_id);
                $issues[] = self::issue(
                    'focus_keyword',
                    'warning',
                    'Focus keyword ' . esc_html($keyword) . ' is already used on another post: ' . esc_html($conflict_title) . '. Choose a different keyword to avoid competing with your own content.',
                    ''
                );
            }
        }

        return min(100, max(0, $points));
    }

    // --------------------------------------------------
    // Readability scoring — returns 0-100 int
    // --------------------------------------------------
    private static function scoreReadability(
        string $plain,
        array $sentences,
        array $paragraphs,
        string $raw_content,
        int $word_count,
        array &$issues
    ): int {
        if ($word_count < 10) {
            return 0;
        }

        $points = 0;

        // average sentence length — target under 20 words
        $avg_sentence = self::avgWordsPerSentence($sentences);
        if ($avg_sentence <= 20) {
            $points += self::READ_WEIGHTS['sentence_length'];
        } elseif ($avg_sentence <= 25) {
            $points += (int) round(self::READ_WEIGHTS['sentence_length'] * 0.5);
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                'Some sentences are long (avg ' . round($avg_sentence, 1) . ' words). Try to keep sentences under 20 words.',
                self::paragraphFixPrompt(
                    self::longSentenceParagraphsRaw($raw_content),
                    'Shorten long sentences. For every sentence that is over 18 words, split it into two separate sentences each under 14 words. You MUST reword the split naturally -- do not just cut at a conjunction. Preserve the original meaning exactly. Do not touch any sentence that is already under 18 words. Do not add or remove paragraphs.'
                ),
                'paragraphs'
            );
        } else {
            $issues[] = self::issue(
                'post_content',
                'warning',
                'Sentences are too long (avg ' . round($avg_sentence, 1) . ' words). Aim for under 20 words.',
                self::paragraphFixPrompt(
                    self::longSentenceParagraphsRaw($raw_content),
                    'Shorten long sentences. For every sentence that is over 18 words, split it into two separate sentences each under 14 words. You MUST reword the split naturally -- do not just cut at a conjunction. Preserve the original meaning exactly. Do not touch any sentence that is already under 18 words. Do not add or remove paragraphs.'
                ),
                'paragraphs'
            );
        }

        // paragraph length — target under 150 words
        $max_para = self::maxWordsInParagraph($paragraphs);
        if ($max_para <= 150) {
            $points += self::READ_WEIGHTS['paragraph_length'];
        } elseif ($max_para <= 200) {
            $points += (int) round(self::READ_WEIGHTS['paragraph_length'] * 0.5);
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                'One or more paragraphs are on the long side (' . $max_para . ' words). Consider breaking them up.',
                self::paragraphFixPrompt(
                    self::longParagraphsRaw($raw_content, 150),
                    'Split this paragraph into shorter paragraphs of no more than 3 sentences each. Only insert paragraph breaks - do not change any existing words.'
                ),
                'paragraphs'
            );
        } else {
            $issues[] = self::issue(
                'post_content',
                'warning',
                'One or more paragraphs are too long (' . $max_para . ' words). Keep paragraphs under 150 words.',
                self::paragraphFixPrompt(
                    self::longParagraphsRaw($raw_content, 150),
                    'Split this paragraph into shorter paragraphs of no more than 3 sentences each. Only insert paragraph breaks - do not change any existing words.'
                ),
                'paragraphs'
            );
        }

        // passive voice — target under 10% of sentences
        $passive_ratio = self::passiveVoiceRatio($sentences);
        if ($passive_ratio <= 0.10) {
            $points += self::READ_WEIGHTS['passive_voice'];
        } elseif ($passive_ratio <= 0.20) {
            $points += (int) round(self::READ_WEIGHTS['passive_voice'] * 0.5);
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                round($passive_ratio * 100) . '% of sentences use passive voice. Try to keep it under 10%.',
                self::paragraphFixPrompt(
                    self::passiveParagraphsRaw($raw_content),
                    'Rewrite every passive voice sentence in these paragraphs to use active voice. Do not change meaning, add content, or remove anything.'
                ),
                'paragraphs'
            );
        } else {
            $issues[] = self::issue(
                'post_content',
                'warning',
                round($passive_ratio * 100) . '% of sentences use passive voice. Keep it under 10%.',
                self::paragraphFixPrompt(
                    self::passiveParagraphsRaw($raw_content),
                    'Rewrite every passive voice sentence in these paragraphs to use active voice. Do not change meaning, add content, or remove anything.'
                ),
                'paragraphs'
            );
        }

        // transition words — target at least 30% of sentences
        $transition_ratio = self::transitionWordRatio($sentences);
        if ($transition_ratio >= 0.30) {
            $points += self::READ_WEIGHTS['transition_words'];
        } elseif ($transition_ratio >= 0.20) {
            $points += (int) round(self::READ_WEIGHTS['transition_words'] * 0.5);
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                'Only ' . round($transition_ratio * 100) . '% of sentences use transition words. Aim for 30%.',
                self::paragraphFixPrompt(
                    self::noTransitionParagraphsRaw($raw_content),
                    'Add one transition word or phrase to the start of each paragraph where it fits naturally. Use words like: however, furthermore, additionally, as a result, therefore, meanwhile, in contrast, for example, in fact, ultimately. Only add - do not remove or rewrite any existing content.'
                ),
                'paragraphs'
            );
        } else {
            $issues[] = self::issue(
                'post_content',
                'warning',
                'Very few transition words found (' . round($transition_ratio * 100) . '%). Add transitions to improve flow.',
                self::paragraphFixPrompt(
                    self::noTransitionParagraphsRaw($raw_content),
                    'Add one transition word or phrase to the start of each paragraph where it fits naturally. Use words like: however, furthermore, additionally, as a result, therefore, meanwhile, in contrast, for example, in fact, ultimately. Only add - do not remove or rewrite any existing content.'
                ),
                'paragraphs'
            );
        }

        // subheading distribution — one H2/H3 per 300 words
        if (self::subheadingDistributionOk($raw_content, $word_count)) {
            $points += self::READ_WEIGHTS['subheading_dist'];
        } elseif ($word_count >= 300) {
            $issues[] = self::issue(
                'post_content',
                'suggestion',
                'Add subheadings (H2 or H3) to break up the content. Aim for one per 300 words.',
                ''
            );
        }

        return min(100, max(0, $points));
    }

    // --------------------------------------------------
    // Build a structured issue array
    // --------------------------------------------------
    private static function issue(
        string $field,
        string $severity,
        string $message,
        string $fix_prompt,
        string $fix_mode = ''
    ): array {
        return [
            'field'      => $field,
            'severity'   => $severity,
            'message'    => $message,
            'fix_prompt' => $fix_prompt,
            'fix_mode'   => $fix_mode,
        ];
    }

    // --------------------------------------------------
    // Strip HTML — return plain text
    // --------------------------------------------------
    private static function stripContent(string $content): string
    {
        $content = wp_strip_all_tags($content);
        $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        return trim((string) preg_replace('/\s+/', ' ', $content));
    }

    // --------------------------------------------------
    // Word count
    // --------------------------------------------------
    private static function wordCount(string $plain): int
    {
        if ($plain === '') {
            return 0;
        }
        return str_word_count($plain);
    }

    // --------------------------------------------------
    // Split plain text into sentences
    // --------------------------------------------------
    private static function splitSentences(string $plain): array
    {
        if ($plain === '') {
            return [];
        }
        $parts = preg_split('/(?<=[.!?])\s+/', $plain, -1, PREG_SPLIT_NO_EMPTY);
        if (!is_array($parts)) {
            return [];
        }
        return array_values(array_filter($parts, static function (string $s): bool {
            return trim($s) !== '';
        }));
    }

    // --------------------------------------------------
    // Split raw HTML content into paragraphs
    // --------------------------------------------------
    private static function splitParagraphs(string $raw): array
    {
        preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $raw, $matches);
        if (!empty($matches[1])) {
            return array_map(static function (string $p): string {
                return wp_strip_all_tags($p);
            }, $matches[1]);
        }
        // No <p> tags - split on newlines (classic editor plain storage)
        $plain = wp_strip_all_tags($raw);
        $plain = str_replace("\r", '', $plain); // strip CRLF from AI-generated content
        $parts = preg_split('/\n+/', $plain, -1, PREG_SPLIT_NO_EMPTY);
        if (is_array($parts) && count($parts) > 1) {
            return array_values(array_filter($parts, static function (string $p): bool {
                return trim($p) !== '';
            }));
        }
        return [$plain];
    }
    // Returns raw <p>...</p> HTML strings from post content
    // Falls back to double-newline splitting for plain-text posts with no <p> tags
    private static function splitParagraphsRaw(string $raw): array
    {
        preg_match_all('/<p[^>]*>.*?<\/p>/is', $raw, $matches);
        if (!empty($matches[0])) {
            return $matches[0];
        }
        // Fallback: split on heading tags, treat each text block between headings as a paragraph
        $segments = preg_split('/<h[1-6][^>]*>.*?<\/h[1-6]>/is', $raw) ?: [];
        $out = [];
        foreach ($segments as $segment) {
            $segment = trim(wp_strip_all_tags($segment));
            if ($segment === '') {
                continue;
            }
            // Split on double newlines if present, otherwise treat whole block as one paragraph
            $chunks = preg_split('/\n{2,}/', $segment, -1, PREG_SPLIT_NO_EMPTY) ?: [$segment];
            foreach ($chunks as $chunk) {
                $chunk = trim($chunk);
                if ($chunk !== '') {
                    $out[] = '<p>' . $chunk . '</p>';
                }
            }
        }
        return $out;
    }
    // Returns array of {heading, word_count} for H2/H3 sections under $min_words
    private static function thinSections(string $raw, int $min_words = 150): array
    {
        // Split on H2/H3 tags -- each section is from one heading to the next
        $parts = preg_split('/<(h2|h3)(\\b[^>]*)>/i', $raw, -1, PREG_SPLIT_DELIM_CAPTURE);
        if (empty($parts) || count($parts) < 4) { return []; }
        // Build a global index of all <p> tags in the full post for surgical patching
        $all_paras = self::splitParagraphsRaw($raw);
        $out = [];
        // parts: [before_first_heading, tag, attrs, content, tag, attrs, content, ...]
        // After preg_split with PREG_SPLIT_DELIM_CAPTURE: [pre, h2|h3, attrs, rest, h2|h3, attrs, rest ...]
        $i = 1;
        while ($i < count($parts)) {
            $tag     = $parts[$i]     ?? '';
            $attrs   = $parts[$i + 1] ?? '';
            $content = $parts[$i + 2] ?? '';
            // Heading text: strip up to closing </h2> or </h3>
            $close_tag    = '</' . $tag . '>';
            $heading_end  = mb_stripos($content, $close_tag);
            $heading_text = $heading_end !== false ? mb_substr($content, 0, $heading_end) : mb_substr($content, 0, 80);
            $heading_text = wp_strip_all_tags($heading_text);
            // Section body: everything after </hN> up to next heading (already split off)
            $body_start = $heading_end !== false ? $heading_end + mb_strlen($close_tag) : 0;
            $body       = mb_substr($content, $body_start);
            $body_plain = wp_strip_all_tags($body);
            $wc         = count(array_filter(preg_split('/\\s+/u', trim($body_plain)) ?: []));
            if ($wc > 0 && $wc < $min_words) {
                // Find which global paragraph indexes belong to this section body
                $section_paras = [];
                foreach ($all_paras as $pidx => $para_html) {
                    // Match by HTML content, or by plain-text against stripped body
                    $para_plain = wp_strip_all_tags($para_html);
                    if (mb_stripos($body, $para_html) !== false || ($para_plain !== '' && mb_stripos($body_plain, $para_plain) !== false)) {
                        $section_paras[] = ['index' => $pidx, 'html' => $para_html];
                    }
                }
                $out[] = ['heading' => $heading_text, 'word_count' => $wc, 'paragraphs' => $section_paras];
            }
            $i += 3;
        }
        return $out;
    }
    // Returns true if the first substantive block in the post is an H2
    private static function hasLeadingH2(string $raw): bool
    {
        // Strip whitespace and find the first HTML tag
        $trimmed = ltrim($raw);
        return (bool) preg_match('/^<h2[\s>]/i', $trimmed);
    }


    // Returns raw <p> blocks that contain at least one long sentence (> $threshold words)
    private static function longSentenceParagraphsRaw(string $raw, int $threshold = 18): array
    {
        $all_paras = self::splitParagraphsRaw($raw);
        $candidates = [];
        foreach ($all_paras as $global_idx => $para) {
            $text      = wp_strip_all_tags($para);
            $sentences = preg_split('/(?<=[.!?])\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY);
            if (!is_array($sentences)) { continue; }
            $max_words = 0;
            foreach ($sentences as $s) {
                $wc = str_word_count($s);
                if ($wc > $threshold && $wc > $max_words) { $max_words = $wc; }
            }
            if ($max_words > 0) {
                $candidates[] = ['index' => $global_idx, 'html' => $para, 'max_words' => $max_words];
            }
        }
        // Sort worst offenders first, cap at 5 to keep prompt manageable
        usort($candidates, function ($a, $b) { return $b['max_words'] - $a['max_words']; });
        $candidates = array_slice($candidates, 0, 5);
        return array_map(function ($c) { return ['index' => $c['index'], 'html' => $c['html']]; }, $candidates);
    }
    // Returns raw <p> blocks that are over $threshold words
    private static function longParagraphsRaw(string $raw, int $threshold = 150): array
    {
        $all_paras = self::splitParagraphsRaw($raw);
        $out       = [];
        foreach ($all_paras as $global_idx => $para) {
            $text = wp_strip_all_tags($para);
            if (str_word_count($text) > $threshold) {
                $out[] = ['index' => $global_idx, 'html' => $para];
            }
        }
        return $out;
    }
    // Returns raw <p> blocks containing passive voice constructions
    private static function passiveParagraphsRaw(string $raw): array
    {
        $paras = self::splitParagraphsRaw($raw);
        $out   = [];
        $pattern = '/\b(am|is|are|was|were|be|been|being)\s+[a-z]+ed\b/i';
        foreach ($paras as $global_idx => $para) {
            $text = wp_strip_all_tags($para);
            if (preg_match($pattern, $text)) { $out[] = ['index' => $global_idx, 'html' => $para]; }
        }
        return $out;
    }
    // Returns raw <p> blocks that have no transition words
    private static function noTransitionParagraphsRaw(string $raw): array
    {
        $paras   = self::splitParagraphsRaw($raw);
        $out     = [];
        $words   = ['however','furthermore','additionally','as a result','therefore',
                    'meanwhile','in contrast','for example','in fact','ultimately',
                    'moreover','consequently','nevertheless','on the other hand',
                    'in addition','first','second','third','finally','also'];
        foreach ($paras as $global_idx => $para) {
            $text  = strtolower(wp_strip_all_tags($para));
            $found = false;
            foreach ($words as $w) { if (str_contains($text, $w)) { $found = true; break; } }
            if (!$found) { $out[] = ['index' => $global_idx, 'html' => $para]; }
        }
        if (empty($out)) {
            foreach ($paras as $global_idx => $para) {
                $out[] = ['index' => $global_idx, 'html' => $para];
            }
        }
        return $out;
    }
    // Builds a surgical fix prompt for paragraph-level changes
    // Returns the prompt string; AI must return JSON array of {original, fixed} objects
    private static function paragraphFixPrompt(array $raw_paras, string $instruction): string
    {
        if (empty($raw_paras)) { return ''; }
        $indexed = [];
        foreach ($raw_paras as $item) {
            $indexed[] = ['index' => (int) ($item['index'] ?? 0), 'html' => ($item['html'] ?? $item)];
        }
        $json = json_encode($indexed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return 'TASK: ' . $instruction . ' '
            . 'RULES: (1) Fix ONLY the paragraphs provided. (2) Do not add, remove, or reorder paragraphs. '
            . '(3) Return ONLY a valid JSON array of objects, each with "index" (integer, same as input) '
            . 'and "fixed" (corrected paragraph HTML). (4) Preserve all existing HTML tags. '
            . '(5) No preamble, no explanation, no markdown fences - raw JSON only. '
            . '(6) Use hyphens only, never em-dashes. '
            . 'Paragraphs to fix (JSON array of {index, html} objects): ' . mb_substr($json, 0, 12000);
    }

    // --------------------------------------------------
    // First paragraph plain text
    // --------------------------------------------------
    private static function firstParagraph(string $plain): string
    {
        return $plain;
    }
    private static function firstParagraphFromRaw(string $raw): string
    {
        if (preg_match('/<p[^>]*>(.*?)<\/p>/is', $raw, $m)) {
            return wp_strip_all_tags($m[1]);
        }
        // No <p> tags -- strip block-level headings first, then take first non-empty chunk
        $stripped = preg_replace('/<h[1-6][^>]*>.*?<\/h[1-6]>/is', '', $raw);
        $plain    = wp_strip_all_tags($stripped ?? $raw);
        $parts    = preg_split('/\n+/', $plain, -1, PREG_SPLIT_NO_EMPTY);
        if (is_array($parts)) {
            foreach ($parts as $part) {
                $part = trim($part);
                if (strlen($part) > 20) { return $part; }
            }
        }
        return trim($plain);
    }
    private static function firstSentenceFromParagraph(string $para): string
    {
        if ($para === '') { return ''; }
        // Split on sentence-ending punctuation followed by whitespace or end of string
        $parts = preg_split('/(?<=[.!?])\\s+/u', trim($para), 2);
        if (is_array($parts) && isset($parts[0]) && trim($parts[0]) !== '') {
            return trim($parts[0]);
        }
        return $para;
    }

    // --------------------------------------------------
    // Average words per sentence
    // --------------------------------------------------
    private static function avgWordsPerSentence(array $sentences): float
    {
        if (count($sentences) === 0) {
            return 0.0;
        }
        $total = 0;
        foreach ($sentences as $s) {
            $total += str_word_count($s);
        }
        return $total / count($sentences);
    }

    // --------------------------------------------------
    // Average words per paragraph
    // --------------------------------------------------
    private static function avgWordsPerParagraph(array $paragraphs): float
    {
        $non_empty = array_filter($paragraphs, static function (string $p): bool {
            return trim($p) !== '';
        });
        if (count($non_empty) === 0) {
            return 0.0;
        }
        $total = 0;
        foreach ($non_empty as $p) {
            $total += str_word_count($p);
        }
        return $total / count($non_empty);
    }

    // --------------------------------------------------
    // Max words in any single paragraph
    // --------------------------------------------------
    private static function maxWordsInParagraph(array $paragraphs): int
    {
        $max = 0;
        foreach ($paragraphs as $p) {
            if (trim($p) === '') { continue; }
            $count = str_word_count($p);
            if ($count > $max) { $max = $count; }
        }
        return $max;
    }

    // --------------------------------------------------
    // Passive voice ratio
    // --------------------------------------------------
    private static function passiveVoiceRatio(array $sentences): float
    {
        if (count($sentences) === 0) {
            return 0.0;
        }
        $pattern = '/\b(' . implode('|', self::PASSIVE_AUXILIARIES) . ')\s+\w+ed\b/i';
        $count   = 0;
        foreach ($sentences as $sentence) {
            if (preg_match($pattern, $sentence)) {
                $count++;
            }
        }
        return $count / count($sentences);
    }

    // --------------------------------------------------
    // Transition word ratio
    // --------------------------------------------------
    private static function transitionWordRatio(array $sentences): float
    {
        if (count($sentences) === 0) {
            return 0.0;
        }
        $count = 0;
        foreach ($sentences as $sentence) {
            $lower = strtolower($sentence);
            foreach (self::TRANSITION_WORDS as $word) {
                if (str_contains($lower, $word)) {
                    $count++;
                    break;
                }
            }
        }
        return $count / count($sentences);
    }

    // --------------------------------------------------
    // Flesch reading ease approximation
    // 206.835 - (1.015 * avg words/sentence) - (84.6 * avg syllables/word)
    // --------------------------------------------------

    // --------------------------------------------------
    // Syllable count approximation
    // --------------------------------------------------
    private static function countSyllables(string $word): int
    {
        $word = strtolower((string) preg_replace('/[^a-zA-Z]/', '', $word));
        if (mb_strlen($word) <= 3) {
            return 1;
        }
        $word = (string) preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $word);
        $word = (string) preg_replace('/^y/', '', $word);
        $matches = [];
        preg_match_all('/[aeiouy]{1,2}/', $word, $matches);
        return max(1, count($matches[0]));
    }

    // --------------------------------------------------
    // Subheading distribution check
    // --------------------------------------------------
    private static function subheadingDistributionOk(string $raw, int $word_count): bool
    {
        if ($word_count < 300) {
            return true;
        }
        preg_match_all('/<h[23][^>]*>/i', $raw, $matches);
        $needed = (int) floor($word_count / 300);
        return count($matches[0]) >= $needed;
    }

    // --------------------------------------------------
    // Count internal links
    // --------------------------------------------------
    private static function countInternalLinks(string $raw): int
    {
        $home = home_url();
        preg_match_all('/<a[^>]+href=["\']([^"\']+)["\']/i', $raw, $matches);
        if (empty($matches[1])) {
            return 0;
        }
        $count = 0;
        foreach ($matches[1] as $href) {
            if (str_starts_with($href, '/') || str_starts_with($href, $home)) {
                $count++;
            }
        }
        return $count;
    }

    // --------------------------------------------------
    // Check for at least one outbound (external) link
    // --------------------------------------------------
    private static function hasOutboundLinks(string $raw): bool
    {
        $home = home_url();
        preg_match_all('/<a[^>]+href=["\']([^"\']+)["\']/i', $raw, $matches);
        if (empty($matches[1])) {
            return false;
        }
        foreach ($matches[1] as $href) {
            if (
                str_starts_with($href, 'http://') || str_starts_with($href, 'https://')
            ) {
                if (!str_starts_with($href, $home)) {
                    return true;
                }
            }
        }
        return false;
    }

    // --------------------------------------------------
    // Check if any image alt contains the keyword
    // --------------------------------------------------
    private static function keywordInImageAlts(string $raw, string $keyword, int $post_id = 0): bool
    {
        if ($keyword === '') {
            return false;
        }
        preg_match_all('/<img[^>]+alt=["\']([^"\']*)["\'][^>]*>/i', $raw, $matches);
        if (empty($matches[1])) {
            // no inline images -- fall through to featured image check
        }
        foreach ($matches[1] as $alt) {
            if (mb_stripos($alt, $keyword) !== false) {
                return true;
            }
        }
        // also check featured image alt
        if ($post_id > 0) {
            $thumb_id = (int) get_post_thumbnail_id($post_id);
            if ($thumb_id > 0) {
                $thumb_alt = (string) get_post_meta($thumb_id, '_wp_attachment_image_alt', true);
                if ($thumb_alt !== '' && mb_stripos($thumb_alt, $keyword) !== false) {
                    return true;
                }
            }
        }
        return false;
    }

    // --------------------------------------------------
    // Ratio of images that have non-empty alt text
    // --------------------------------------------------
    private static function imageAltScore(string $raw): float
    {
        preg_match_all('/<img[^>]+>/i', $raw, $img_matches);
        $total = count($img_matches[0]);
        if ($total === 0) {
            return 1.0;
        }
        preg_match_all('/<img[^>]+alt=["\'][^"\']+["\'][^>]*>/i', $raw, $alt_matches);
        return count($alt_matches[0]) / $total;
    }

    // --------------------------------------------------
    // Register hooks
    // --------------------------------------------------
    public static function register(): void
    {
        add_action('save_post', [self::class, 'onSavePost'], 20, 2);
    }

    // --------------------------------------------------
    // save_post callback — analyze on publish/update
    // --------------------------------------------------
    public static function onSavePost(int $post_id, \WP_Post $post): void
    {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }
        if (wp_is_post_revision($post_id)) {
            return;
        }
        if ($post->post_status === 'auto-draft' || $post->post_status === 'trash') {
            return;
        }
        $post_type_obj = get_post_type_object($post->post_type);
        if (!$post_type_obj instanceof \WP_Post_Type || !$post_type_obj->public) {
            return;
        }
        self::analyze($post_id);
    }
}