<?php
/**
 * Rank Forge — pure scoring engine.
 *
 * RankMath-style EARN model (redesign): the score starts at 0 and the
 * post earns points by passing each test, summed to 0–100 across four groups
 * (Basic SEO / Additional / Title Readability / Content Readability). Failed
 * tests surface as issues (guidance) but never "deduct" — adding/optimising
 * always raises the score, never lowers it.
 *
 * Pure: no WordPress calls, no DB reads, no side effects. The caller
 * (class-analyzer.php) pre-fetches everything and passes it as a plain array;
 * content-derived signals are read from the resolved `content_html`. Mirrored
 * by NOTHING on the client — admin.js asks the server for the score (single
 * source of truth) so the live and saved numbers can never diverge.
 *
 * @package RankForge
 */

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

if (!class_exists('RANKFORGE_Scorer')) {

class RANKFORGE_Scorer {

    /**
 * Per-test point weights (sum = 100). One tunable map — change a weight
 * here and both the score and the displayed "worth" follow. Grouped to
 * mirror RankMath's content-analysis layout.
 */
    const WEIGHTS = [
        // ── Basic SEO (30) ──
        'kw_in_title'        => 5,
        'kw_in_description'  => 4,
        'kw_in_url'          => 3,
        'kw_in_intro'        => 4,
        'kw_in_content'      => 4,
        'content_length'     => 10,
        // ── Additional (25) ──
        'kw_in_subheadings'  => 4,
        'kw_in_alt'          => 3,
        'kw_density'         => 3,
        'internal_links'     => 4,
        'external_links'     => 3,
        'url_length'         => 3,
        'media'              => 5,
        // ── Title Readability (15) ──
        'title_length'       => 6,
        'kw_title_start'     => 3,
        'title_number'       => 3,
        'title_power_word'   => 3,
        // ── Content Readability (30) ──
        'description_length' => 6,
        'short_paragraphs'   => 5,
        'sentence_length'    => 5,
        'flesch'             => 5,
        'passive_voice'      => 3,
        'has_headings'       => 3,
        'table_of_contents'  => 3,
    ];

    /** Power / emotional words for the title-quality test (English). */
    const POWER_WORDS = [
        'ultimate','essential','complete','proven','easy','quick','fast','best','free','new',
        'amazing','powerful','simple','effortless','guaranteed','secret','expert','definitive',
        'comprehensive','guide','tips','top','incredible','stunning','remarkable','surprising',
        'effective','smart','perfect','step-by-step','how','why','must','crucial','important',
        'unbelievable','exclusive','instant','epic','brilliant','genius',
    ];

    /**
 * Score one post against the rubric.
 *
 * $inputs keys:
 * post_title, meta_title, seo_title (raw), meta_desc, content (plain),
 * content_html (resolved HTML), raw_content (post_content), keyword,
 * is_english (bool), slug (string), has_image_url (bool),
 * has_image_meta (bool).
 *
 * @return array { score:int, issues:array<{severity,message,group,points,fixable}>, suggestions:string }
 */
    public static function score(array $inputs) {
        $W = self::WEIGHTS;

        $post_title    = (string) ($inputs['post_title']   ?? '');
        $meta_title    = (string) ($inputs['meta_title']   ?? '');
        $seo_title     = trim((string) ($inputs['seo_title'] ?? $inputs['meta_title'] ?? ''));
        $meta_desc     = trim((string) ($inputs['meta_desc'] ?? ''));
        $content       = (string) ($inputs['content']      ?? '');
        $content_html  = (string) ($inputs['content_html'] ?? '');
        $raw_content   = (string) ($inputs['raw_content']  ?? '');
        $keyword       = trim((string) ($inputs['keyword'] ?? ''));
        $is_english    = (bool)   ($inputs['is_english']   ?? false);
        $slug          = trim((string) ($inputs['slug']    ?? ''));
        $has_image_url  = (bool)  ($inputs['has_image_url'] ?? false);
        $has_image_meta = (bool)  ($inputs['has_image_meta'] ?? false);

        // Effective title (what Google renders): SEO title, else post title.
        $eff_title = $seo_title !== '' ? $seo_title : ($meta_title ?: $post_title);

        // ─────────── Derive content signals from the resolved HTML ───────────
        $word_count = self::word_count($content);

        preg_match_all('/<h([2-6])[^>]*>(.*?)<\/h\1>/is', $content_html, $hm);
        $has_headings    = !empty($hm[0]);
        $subheading_text = !empty($hm[2]) ? self::strip(implode(' ', $hm[2])) : '';

        preg_match_all('/<img[^>]*>/i', $content_html, $imgs);
        $img_count = count($imgs[0]);
        $alt_texts = '';
        foreach ($imgs[0] as $img) {
            if (preg_match('/alt\s*=\s*["\']([^"\']*)["\']/i', $img, $am)) {
                $alt_texts .= ' ' . $am[1];
            }
        }
        $has_video = (bool) preg_match('/<video[\s>]|<iframe[^>]+(youtube|youtu\.be|vimeo|wistia|dailymotion|loom)/i', $content_html);
        $has_media = ($img_count > 0) || $has_video || $has_image_url || $has_image_meta;

        preg_match_all('/<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $content_html, $lm);
        $internal = 0; $external = 0; $anchor_frag = 0;
        foreach (($lm[1] ?? []) as $href) {
            $href = trim($href);
            if ($href === '' || $href === '#') continue;
            if ($href[0] === '#') { $anchor_frag++; continue; }
            $is_abs = (stripos($href, 'http') === 0 || strpos($href, '//') === 0);
            if ($is_abs && !self::is_internal_href($href)) $external++;
            else $internal++;
        }
        $has_toc = (stripos($content_html, 'rankforge-toc') !== false) || ($anchor_frag >= 3) || (mb_stripos($content, 'table of contents') !== false);

        // ─────────── Keyword presence ───────────
        $has_kw = $keyword !== '';
        $intro_len   = max(300, (int) round(mb_strlen($content) * 0.10));
        $intro       = mb_substr($content, 0, $intro_len);
        $kw_content  = $has_kw && self::keyword_present($keyword, $content);
        $kw_intro    = $has_kw && self::keyword_present($keyword, $intro);
        $kw_title    = $has_kw && self::keyword_present($keyword, $eff_title);
        $kw_desc     = $has_kw && $meta_desc !== '' && self::keyword_present($keyword, $meta_desc);
        $kw_url      = $has_kw && $slug !== '' && self::keyword_present($keyword, str_replace(['-', '_'], ' ', $slug));
        $kw_subhead  = $has_kw && $subheading_text !== '' && self::keyword_present($keyword, $subheading_text);
        $kw_alt      = $has_kw && $alt_texts !== '' && self::keyword_present($keyword, $alt_texts);

        // Round to one decimal so the threshold check agrees with the value we
        // display (raw 2.509% must not read "too high (2.5%)").
        $density = $kw_content ? round(self::keyword_density($keyword, $content, $word_count), 1) : 0.0;

        $issues = [];
        $earned = 0;

        /**
 * Record a test result. $ok full pass; $partial (0..1) for graded
 * tests. On a miss, push an issue for guidance.
 */
        $check = function ($key, $ok, $issue = null, $group = 'basic', $partial = null) use (&$earned, &$issues, $W) {
            $weight = $W[$key] ?? 0;
            if ($ok === true) {
                $earned += $weight;
                return;
            }
            $got = ($partial !== null) ? (int) round($weight * $partial) : 0;
            $earned += $got;
            if ($issue) {
                $issue['group']  = $issue['group'] ?? $group;
                $issue['points'] = $weight; // what passing this test is worth
                $issue['code']   = $issue['code'] ?? $key; // machine code → help tooltip + docs link
                $issues[] = $issue;
            }
        };

        // ══════════ Basic SEO ══════════
        if (!$has_kw) {
            $issues[] = ['severity' => 'warning', 'message' => __('No focus keyword set', 'rankforge'), 'group' => 'basic', 'points' => $W['kw_in_title'], 'code' => 'focus_keyword'];
        }
        $check('kw_in_title', $kw_title, $has_kw ? ['severity' => 'warning', 'message' => __('Focus keyword not in title', 'rankforge')] : null, 'basic');
        $check('kw_in_description', $kw_desc, ($has_kw && $meta_desc !== '') ? ['severity' => 'info', 'message' => __('Focus keyword not in meta description', 'rankforge')] : null, 'basic');
        $check('kw_in_url', $kw_url, ($has_kw && $slug !== '') ? ['severity' => 'info', 'message' => __('Focus keyword not in URL', 'rankforge')] : null, 'basic');
        $check('kw_in_intro', $kw_intro, $has_kw ? ['severity' => 'info', 'message' => __('Focus keyword not in first paragraph', 'rankforge')] : null, 'basic');
        $check('kw_in_content', $kw_content, $has_kw ? ['severity' => 'error', 'message' => __('Focus keyword not found in content', 'rankforge')] : null, 'basic');

        // Content length — graded.
        if ($word_count >= 600)      $check('content_length', true, null, 'basic');
        /* translators: %d: word count of the content */
        elseif ($word_count >= 300)  $check('content_length', false, ['severity' => 'info', 'message' => sprintf(__('Content is %d words — 600+ scores best', 'rankforge'), $word_count)], 'basic', 0.7);
        /* translators: %d: word count of the content */
        elseif ($word_count >= 100)  $check('content_length', false, ['severity' => 'warning', 'message' => sprintf(__('Content could be longer (%d words, aim for 300+)', 'rankforge'), $word_count)], 'basic', 0.4);
        /* translators: %d: word count of the content */
        else                         $check('content_length', false, ['severity' => 'error', 'message' => sprintf(__('Content too short (%d words, aim for 300+)', 'rankforge'), $word_count)], 'basic', 0.0);

        // ══════════ Additional ══════════
        $check('kw_in_subheadings', $kw_subhead, ($has_kw && $has_headings) ? ['severity' => 'info', 'message' => __('Focus keyword not in subheadings', 'rankforge')] : null, 'additional');
        $check('kw_in_alt', $kw_alt, ($has_kw && $img_count > 0) ? ['severity' => 'info', 'message' => __('Focus keyword not in image alt text', 'rankforge')] : null, 'additional');

        // Keyword density acceptable range 0.5–3% (Yoast's range). The upper
        // bound is 3, not 2.5, because our multi-word density counts the rarest

        // sit near the top of the band — 2.5 would wrongly flag them "too high".
        if ($kw_content) {
            if ($density >= 0.5 && $density <= 3.0) {
                $check('kw_density', true, null, 'additional');
            } elseif ($density > 3.0) {
                /* translators: %.1f: keyword density as a percentage */
                $check('kw_density', false, ['severity' => 'warning', 'message' => sprintf(__('Keyword density too high (%.1f%%)', 'rankforge'), $density)], 'additional');
            } else {
                /* translators: %.1f: keyword density as a percentage */
                $check('kw_density', false, ['severity' => 'info', 'message' => sprintf(__('Keyword density low (%.1f%%, aim for 0.5–3%%)', 'rankforge'), $density)], 'additional');
            }
        }

        $check('internal_links', $internal > 0, ['severity' => 'warning', 'message' => __('No internal links found', 'rankforge')], 'additional');
        $check('external_links', $external > 0, ['severity' => 'info', 'message' => __('No external links found', 'rankforge')], 'additional');

        // URL length — auto-pass when there's no slug yet (new post).
        if ($slug === '' || mb_strlen($slug) <= 75) $check('url_length', true, null, 'additional');
        else $check('url_length', false, ['severity' => 'info', 'message' => __('URL is long (over 75 chars)', 'rankforge')], 'additional');

        $check('media', $has_media, ['severity' => 'info', 'message' => __('No images or video in content', 'rankforge')], 'additional');

        // ══════════ Title Readability ══════════
        if ($seo_title === '') {
            $check('title_length', false, ['severity' => 'error', 'message' => __('SEO title is missing', 'rankforge')], 'title', 0.0);
        } else {
            $tlen = mb_strlen($seo_title);
            if ($tlen >= 20 && $tlen <= 60)      $check('title_length', true, null, 'title');
            elseif ($tlen < 20)                  $check('title_length', false, ['severity' => 'warning', 'message' => __('SEO title too short (under 20 chars)', 'rankforge')], 'title', 0.5);
            else                                 $check('title_length', false, ['severity' => 'warning', 'message' => __('SEO title too long (over 60 chars)', 'rankforge')], 'title', 0.5);
        }
        $check('kw_title_start', $has_kw && self::keyword_at_start($keyword, $eff_title), $has_kw ? ['severity' => 'info', 'message' => __('Focus keyword not near the start of the title', 'rankforge')] : null, 'title');
        // numbers / power words in the title are click-through heuristics,
        // NOT Google ranking requirements. Surface them as optional "Optimization
        // ideas" (optional=true) so they don't read as problems or inflate the
        // red Issues count. Wording keeps the "add a number" / "power or emotional"
        // tokens so the AI title-fix matcher still recognises them.
        $check('title_number', (bool) preg_match('/\d/u', $eff_title), ['severity' => 'info', 'optional' => true, 'message' => __('Optional: add a number to the title (e.g. "7 ways…") to lift click-through — not required for ranking', 'rankforge')], 'title');
        // Power word — English only; auto-pass other languages (can't judge).
        $title_power = !$is_english || self::has_power_word($eff_title);
        $check('title_power_word', $title_power, $is_english ? ['severity' => 'info', 'optional' => true, 'message' => __('Optional: add a power or emotional word to the title to lift click-through — not required for ranking', 'rankforge')] : null, 'title');

        // ══════════ Content Readability ══════════
        if ($meta_desc === '') {
            $check('description_length', false, ['severity' => 'error', 'message' => __('Meta description is missing', 'rankforge')], 'content', 0.0);
        } else {
            $dlen = mb_strlen($meta_desc);
            if ($dlen >= 120 && $dlen <= 160)    $check('description_length', true, null, 'content');
            elseif ($dlen < 120)                 $check('description_length', false, ['severity' => 'warning', 'message' => __('Meta description too short (under 120 chars)', 'rankforge')], 'content', 0.5);
            else                                 $check('description_length', false, ['severity' => 'warning', 'message' => __('Meta description too long (over 160 chars)', 'rankforge')], 'content', 0.5);
        }

        // Sentence/paragraph/readability — need real sentences.
        $sentences = array_filter(preg_split('/[.!?]+/u', $content), function ($s) { return self::word_count(trim($s)) > 2; });
        $sentence_count = count($sentences);

        // Content-readability tests only earn once there's real text to judge
        // (an empty/thin post shouldn't bank "good readability"). Content-too-
        // short is already flagged, so we don't add duplicate issues here.
        $has_text = ($word_count >= 50 && $sentence_count > 0);

        // Short paragraphs (none over 150 words).
        $long_paras = 0;
        foreach (preg_split('/<\/p>|<br\s*\/?>\s*<br\s*\/?>|\n\n/i', $raw_content) as $para) {
            if (self::word_count(self::strip($para)) > 150) $long_paras++;
        }
        if (!$has_text) {
            $check('short_paragraphs', false, null, 'content', 0.0);
        } else {
            /* translators: %d: number of paragraphs over 150 words */
            $check('short_paragraphs', $long_paras === 0, ['severity' => 'info', 'message' => sprintf(__('%d paragraph(s) over 150 words — break them up', 'rankforge'), $long_paras)], 'content');
        }

        // Sentence length (avg ≤20 words) — graded.
        if (!$has_text) {
            $check('sentence_length', false, null, 'content', 0.0);
        } else {
            $avg = $word_count / $sentence_count;
            if ($avg <= 20)      $check('sentence_length', true, null, 'content');
            /* translators: %.0f: average number of words per sentence */
            elseif ($avg <= 25)  $check('sentence_length', false, ['severity' => 'info', 'message' => sprintf(__('Sentences slightly long (avg %.0f words)', 'rankforge'), $avg)], 'content', 0.5);
            /* translators: %.0f: average number of words per sentence */
            else                 $check('sentence_length', false, ['severity' => 'warning', 'message' => sprintf(__('Sentences too long (avg %.0f words, aim under 20)', 'rankforge'), $avg)], 'content', 0.0);
        }

        // Flesch + passive — English only; auto-pass other languages (can't
        // judge fairly), 0 when there's not enough text yet.
        if (!$has_text) {
            $check('flesch', false, null, 'content', 0.0);
            $check('passive_voice', false, null, 'content', 0.0);
        } elseif (!$is_english) {
            $check('flesch', true, null, 'content');
            $check('passive_voice', true, null, 'content');
        } else {
            $flesch = self::flesch($content, $word_count, $sentence_count);
            if ($flesch >= 50)      $check('flesch', true, null, 'content');
            /* translators: %d: Flesch reading-ease score */
            elseif ($flesch >= 30)  $check('flesch', false, ['severity' => 'info', 'message' => sprintf(__('Fairly hard to read (Flesch %d, aim 60+)', 'rankforge'), $flesch)], 'content', 0.5);
            /* translators: %d: Flesch reading-ease score */
            else                    $check('flesch', false, ['severity' => 'warning', 'message' => sprintf(__('Very hard to read (Flesch %d, aim 60+)', 'rankforge'), $flesch)], 'content', 0.0);

            $passive_count = preg_match_all('/\b(is|are|was|were|be|been|being)\s+\w+ed\b/i', $content);
            $passive_pct   = ($passive_count / $sentence_count) * 100;
            if ($passive_pct <= 10)      $check('passive_voice', true, null, 'content');
            /* translators: %.0f: percentage of sentences in passive voice */
            elseif ($passive_pct <= 20)  $check('passive_voice', false, ['severity' => 'info', 'message' => sprintf(__('%.0f%% passive voice (aim under 10%%)', 'rankforge'), $passive_pct)], 'content', 0.5);
            /* translators: %.0f: percentage of sentences in passive voice */
            else                         $check('passive_voice', false, ['severity' => 'info', 'message' => sprintf(__('%.0f%% passive voice (aim under 10%%)', 'rankforge'), $passive_pct)], 'content', 0.0);
        }

        $check('has_headings', $has_headings, ['severity' => 'warning', 'message' => __('No headings (H2–H6) found in content', 'rankforge')], 'content');
        $check('table_of_contents', $has_toc, ['severity' => 'info', 'message' => __('No table of contents', 'rankforge')], 'content');

        // ─────────── Final score ───────────
        $score = (int) max(0, min(100, round($earned)));

        $suggestions = [];
        foreach ($issues as $issue) {
            if (($issue['severity'] ?? '') === 'error') $suggestions[] = $issue['message'];
        }

        return [
            'score'       => $score,
            'issues'      => $issues,
            'suggestions' => $suggestions,
        ];
    }

    /** Strip tags without WordPress (scorer stays pure). */
    private static function strip($html) {
        return trim(preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $html)));
    }

    /**
 * Multibyte-aware word count. PHP's str_word_count is ASCII-only — it
 * reads a 359-word Russian post as ~7 words, wrongly flagging it "too
 * short". Here whitespace-separated letter/number runs are counted (works
 * for Latin, Cyrillic, Greek, Arabic, Hebrew…) and CJK characters — which
 * have no spaces — count individually.
 */
    public static function word_count($text) {
        $text = trim((string) $text);
        if ($text === '') return 0;
        $cjk = '\x{3040}-\x{30ff}\x{3400}-\x{4dbf}\x{4e00}-\x{9fff}\x{ac00}-\x{d7af}\x{f900}-\x{faff}';
        preg_match_all('/[' . $cjk . ']/u', $text, $cm);
        $cjk_count = count($cm[0]);
        $rest = preg_replace('/[' . $cjk . ']/u', ' ', $text);
        preg_match_all('/[\p{L}\p{N}]+/u', $rest, $wm);
        return count($wm[0]) + $cjk_count;
    }

    /**
 * Readability stats for an arbitrary content chunk, using the SAME formulas
 * as score. Lets content generation (e.g. AI expand) verify its own
 * output obeys our readability rules before the user ever sees it, so we
 * never hand back content that immediately trips "hard to read" etc.
 *
 * @return array{flesch:?int, avg_sentence:float, passive_pct:float, word_count:int, sentence_count:int}
 */
    public static function readability_stats($content, $is_english = true) {
        $text = self::strip($content);
        $word_count = self::word_count($text);
        $sentences = array_filter(preg_split('/[.!?]+/u', $text), function ($s) { return self::word_count(trim($s)) > 2; });
        $sentence_count = count($sentences);
        $avg = $sentence_count > 0 ? $word_count / $sentence_count : 0.0;
        $flesch = ($is_english && $sentence_count > 0 && $word_count > 0)
            ? self::flesch($text, $word_count, $sentence_count) : null;
        $passive_pct = 0.0;
        if ($is_english && $sentence_count > 0) {
            $passive = preg_match_all('/\b(is|are|was|were|be|been|being)\s+\w+ed\b/i', $text);
            $passive_pct = ($passive / $sentence_count) * 100;
        }
        return [
            'flesch'         => $flesch,
            'avg_sentence'   => round($avg, 1),
            'passive_pct'    => round($passive_pct, 1),
            'word_count'     => $word_count,
            'sentence_count' => $sentence_count,
        ];
    }

    /** Is an absolute href internal (same host as the site)? */
    private static function is_internal_href($href) {
        $home = function_exists('home_url') ? home_url() : '';
        if ($home && strpos($href, $home) !== false) return true;
        $home_host = $home ? wp_parse_url($home, PHP_URL_HOST) : '';
        $href_host = wp_parse_url($href, PHP_URL_HOST);
        return $home_host && $href_host && strcasecmp($home_host, $href_host) === 0;
    }

    /** Keyword (or its first word) appears in the first half of the title. */
    private static function keyword_at_start($keyword, $title) {
        $title_lc = mb_strtolower(trim((string) $title));
        if ($title_lc === '') return false;
        $words = preg_split('/\s+/u', mb_strtolower(trim((string) $keyword)), -1, PREG_SPLIT_NO_EMPTY);
        if (!$words) return false;
        $pos = mb_stripos($title_lc, $words[0]);
        if ($pos === false) return false;
        return $pos <= max(1, (int) round(mb_strlen($title_lc) / 2));
    }

    /** Title contains a power / emotional word. */
    private static function has_power_word($title) {
        $t = ' ' . mb_strtolower((string) $title) . ' ';
        foreach (self::POWER_WORDS as $w) {
            if (mb_strpos($t, $w) !== false) return true;
        }
        return false;
    }

    /** Flesch Reading Ease (approx; vowel-group syllables, EN + RU vowels). */
    private static function flesch($content, $word_count, $sentence_count) {
        $syllables = 0;
        foreach (preg_split('/\s+/u', $content) as $w) {
            $w = mb_strtolower(trim($w));
            if ($w === '') continue;
            if (mb_strlen($w) <= 3) { $syllables += 1; continue; }
            $syllables += max(1, preg_match_all('/[aeiouyаеёиоуыэюя]+/iu', $w));
        }
        $f = 206.835 - (1.015 * ($word_count / $sentence_count)) - (84.6 * ($syllables / max(1, $word_count)));
        return (int) max(0, min(100, round($f)));
    }

    /**
 * Keyphrase presence tolerant of multi-word focus keywords. Single word →
 * substring. Multi-word → exact phrase OR every word present somewhere.
 * Mirrored in admin.js keywordPresent.
 */
    public static function keyword_present($keyword, $haystack) {
        $kw = trim((string) $keyword);
        if ($kw === '') return true;
        if (mb_stripos((string) $haystack, $kw) !== false) return true;
        $words = preg_split('/\s+/u', mb_strtolower($kw), -1, PREG_SPLIT_NO_EMPTY);
        if (count($words) <= 1) return false;
        $hay = mb_strtolower((string) $haystack);
        foreach ($words as $w) {
            if (mb_stripos($hay, $w) === false) return false;
        }
        return true;
    }

    
    public static function keyword_density($keyword, $content, $word_count = null) {
        $kw = trim((string) $keyword);
        if ($kw === '') return 0.0;
        $wc = ($word_count === null) ? self::word_count($content) : (int) $word_count;
        if ($wc <= 0) return 0.0;
        $hay   = mb_strtolower((string) $content);
        $words = preg_split('/\s+/u', mb_strtolower($kw), -1, PREG_SPLIT_NO_EMPTY);
        if (!$words) return 0.0;
        $min = null;
        foreach ($words as $w) {
            $c = mb_substr_count($hay, $w);
            if ($min === null || $c < $min) $min = $c;
        }
        if (!$min) return 0.0;
        return ($min / $wc) * 100;
    }
}

} // end: if (!class_exists('RANKFORGE_Scorer'))
