<?php
if (!defined('ABSPATH')) exit;

class RANKFORGE_Analyzer {
    private static $instance = null;
    public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; }

    private function __construct() {
        // F14 (2026-04-21): pure scoring extracted to RANKFORGE_Scorer.
        // Load it here so analyze can delegate without worrying about
        // load order.
        if (!class_exists('RANKFORGE_Scorer')) {
            require_once __DIR__ . '/class-seo-scorer.php';
        }
        // F14 continued (2026-04-21): SERP + Social preview views.
        if (!class_exists('RANKFORGE_Preview_View')) {
            require_once __DIR__ . '/class-seo-preview.php';
        }
        add_action('save_post', [$this, 'auto_analyze'], 20, 2);
        add_action('add_meta_boxes', [$this, 'add_meta_box']);
        add_action('wp_ajax_rankforge_save_keyword', [$this, 'ajax_save_keyword']);
        add_action('wp_ajax_rankforge_save_meta_field', [$this, 'ajax_save_meta_field']);
        // Round 3 (2026-04-28): F3 — live Issues list. The metabox sends
        // un-saved title / description / focus keyword and we re-run the
        // pure scorer with content-derived signals frozen at page load.
        // Returns the same issues shape the renderer uses, so the JS can
        // rebuild the list without a save round-trip.
        add_action('wp_ajax_rankforge_get_issues', [$this, 'ajax_get_issues']);
    }

    /**
 * Detect whether a post is in English. Source of truth, in order:
 * 1. Lang Forge post meta `_lf_language` (our own translator).
 * 2. Polylang / WPML language APIs if present.
 * 3. Site locale from get_locale.
 * ASCII-ratio heuristics are NOT used — Spanish is 97% Latin-ASCII and
 * would fool them. Readability/passive-voice checks require a reliable
 * "is English" signal.
 */
    public static function is_english_post($post) {
        if (!$post) return false;
        $post_id = is_object($post) ? (int) $post->ID : (int) $post;

        // Lang Forge stores language codes in _lf_language (array of strings).
        $lf = get_post_meta($post_id, '_langforge_language', true);
        if (is_array($lf)) $lf = reset($lf);
        if (is_string($lf) && $lf !== '') {
            return strtolower(substr($lf, 0, 2)) === 'en';
        }

        // Polylang
        if (function_exists('pll_get_post_language')) {
            $code = pll_get_post_language($post_id, 'slug');
            if (is_string($code) && $code !== '') {
                return strtolower(substr($code, 0, 2)) === 'en';
            }
        }

        // WPML
        $wpml = apply_filters('wpml_post_language_details', null, $post_id); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
        if (is_array($wpml) && !empty($wpml['language_code'])) {
            return strtolower(substr($wpml['language_code'], 0, 2)) === 'en';
        }

        // Site locale fallback.
        return strtolower(substr(get_locale(), 0, 2)) === 'en';
    }

    /**
 * The backend action the "Fix" button routes an issue to. Mirrors the JS
 * routing in admin.js sfSmartFixOne — KEEP IN SYNC. Single-field meta
 * generation (keyword / title / description) → generate_meta; missing
 * internal links → auto_insert_links; everything else (content rewrite)
 * → fix_issue.
 */
    public static function fix_action_for_issue($msg) {
        $s = strtolower((string) $msg);
        if (preg_match('/\b(no|missing|empty)\s+focus\s+keyword/', $s) || preg_match('/focus\s+keyword\s+(is\s+)?(missing|empty|not\s+set)/', $s)) return 'generate_meta';
        // Any title-quality issue → regenerate/rewrite the SEO title.
        if ((preg_match('/(meta title|seo.?title|\btitle\b)/', $s) && preg_match('/(missing|too short|too long|not in title|not near the start|add a number|power or emotional)/', $s)) || preg_match('/focus keyword not in title/', $s)) return 'generate_meta';
        if ((preg_match('/(meta description|description)/', $s) && preg_match('/(missing|too short|too long|not in|empty)/', $s)) || preg_match('/focus keyword not in (meta )?description/', $s)) return 'generate_meta';
        if (preg_match('/internal link/', $s)) return 'auto_insert_links';
        // "N paragraph(s) over 150 words — break them up" is fixed
        // deterministically by splitting <p>/blank-line blocks at sentence
        // boundaries (RANKFORGE_Scorer split) — no AI, no credits, and it always
        // resolves the issue (unlike a generic AI rewrite that often reworded
        // the paragraph without splitting it — ).
        if (self::is_issue_paragraph_split($msg)) return 'split_paragraphs';
        return 'fix_issue';
    }

    /**
 * Credit cost of fixing an issue — the real cost of the action it routes
 * to, so the Fix button never overstates the price (a single-field title /
 * description generation is 1 credit, not 2).
 */
    public static function fix_credit_cost($msg) {
        $action = self::fix_action_for_issue($msg);
        if (class_exists('Forge_Credits_Widget') && method_exists('Forge_Credits_Widget', 'get_action_cost')) {
            return (int) Forge_Credits_Widget::get_action_cost($action);
        }
        // fix_issue re-priced 2→5. split_paragraphs is deterministic
        // (RANKFORGE_Scorer split, no AI) so it costs nothing.
        $fallback = ['generate_meta' => 1, 'split_paragraphs' => 0, 'auto_insert_links' => 3, 'fix_issue' => 5];
        return $fallback[$action] ?? 5;
    }

    /**
 * Whether the "Fix" button should appear for an issue — true only for
 * issues our AI endpoints can actually resolve. Structural checks (keyword
 * in URL/subheadings/alt, external links, title number/power word, table of
 * contents, density, media, headings) and media/alt edits have no AI fix,
 * so they stay guidance-only. Content-rewrite fixes need an editable
 * post_content target. Shared by ajax_get_issues and the metabox.
 */
    public static function is_issue_ai_fixable($msg, $has_editable = true) {
        $s = strtolower((string) $msg);
        // Title-quality issues (number / power word / keyword near start) ARE
        // fixable — the title rewrite handles them. Truly structural checks are
        // guidance-only.
        if (preg_match('/not in url|not in subheadings|not in image alt|no external links|table of contents|density|images or video|no headings|url is long/', $s)) return false;
        // Content length isn't a one-click Fix — it gets a separate "Expand"
        // button (review drawer) instead (see is_issue_expandable).
        if (self::is_issue_expandable($msg)) return false;
        // Media / alt can't be fixed by text AI.
        if (preg_match('/missing alt|no images in content/', $s)) return false;
        // Content-rewrite fixes need a post_content editor to target.
        if (!$has_editable && preg_match('/internal links|content (too short|could be longer)|\d+ words|sentences|hard to read|flesch|passive|paragraph/', $s)) return false;
        return true;
    }

    /**
 * Content-length issues. These don't get a one-click Fix — instead they
 * offer an "Expand with AI" button that opens a review drawer (preview the
 * ≥600-word expansion before it touches the post).
 */
    public static function is_issue_expandable($msg) {
        return (bool) preg_match('/content too short|content could be longer|scores best/i', (string) $msg);
    }

    /**
 * "N paragraph(s) over 150 words — break them up" (scorer code
 * `short_paragraphs`). Resolved deterministically by RANKFORGE_Scorer-style
 * sentence-boundary splitting — no AI, no credits. Matches the English
 * message, the "break them up" tail, or the raw code so it works whether
 * the caller has the localized message or the language-neutral code.
 */
    public static function is_issue_paragraph_split($msg) {
        $s = strtolower(trim((string) $msg));
        if ($s === 'short_paragraphs') return true;
        return (bool) (preg_match('/paragraph.*over\s*150\s*words/', $s) || preg_match('/break them up/', $s));
    }

    /**
 * a content-body rewrite (readability / paragraph split) — as
 * opposed to a meta-field or internal-link fix. These now open the review
 * drawer (preview → apply) in the metabox, just like Site Audit, so the
 * user always sees Before/After before the body is touched. Mirrors the
 * routing in fix_action_for_issue.
 */
    public static function is_issue_content_rewrite($msg, $has_editable = true) {
        if (!self::is_issue_ai_fixable($msg, $has_editable)) return false;
        $action = self::fix_action_for_issue($msg);
        return $action === 'fix_issue' || $action === 'split_paragraphs';
    }

    /** "No table of contents" — offers an "Insert ToC" action (needs headings). */
    public static function is_issue_toc($msg) {
        return (bool) preg_match('/table of contents/i', (string) $msg);
    }

    /** "No external links" — offers a "Suggest links" action (AI, reviewed). */
    public static function is_issue_external($msg) {
        return (bool) preg_match('/no external links/i', (string) $msg);
    }

    /**
 * "Focus keyword not in subheadings" — offers an "Add to subheading" action
 * (AI, reviewed): rewrites ONE H2–H4 to include the keyword, leaving the
 * rest of the content untouched.
 */
    public static function is_issue_subheading_keyword($msg) {
        return (bool) preg_match('/keyword not in subheadings/i', (string) $msg);
    }

    /**
 * Per-issue help: a plain-language one-liner + the docs page that explains
 * it. Keyed by the scorer's machine code (RANKFORGE_Scorer $check key) so it
 * is language-neutral. Powers the "?" tooltip next to each issue — a novice
 * who doesn't know what e.g. "Insert ToC" means gets an instant explanation
 * and a "Read the docs" link. All pages are existing /docs/ articles.
 *
 * @return array<string,array{page:string,summary:string}>
 */
    public static function issue_help_map() {
        return [
            'focus_keyword'      => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('The main phrase you want this page to rank for. Rank Forge then checks it appears in the title, description, URL and body.', 'rankforge')],
            'kw_in_title'        => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Your focus keyword should appear in the SEO title so search engines and readers see the page matches their query.', 'rankforge')],
            'kw_title_start'     => ['page' => 'writing-seo-titles-and-descriptions', 'summary' => __('Place the keyword near the start of the title — earlier words carry more weight.', 'rankforge')],
            'kw_in_description'  => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Include the focus keyword in the meta description — the snippet shown under your title in search results.', 'rankforge')],
            'kw_in_url'          => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('A short URL slug containing the keyword is easier to read and tends to rank better.', 'rankforge')],
            'kw_in_content'      => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Use the focus keyword naturally in the body so the page is clearly about that topic.', 'rankforge')],
            'kw_in_intro'        => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Mention the keyword early (first paragraph) so readers and search engines grasp the topic immediately.', 'rankforge')],
            'kw_in_subheadings'  => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Put the keyword in at least one H2/H3 subheading to reinforce what the section is about.', 'rankforge')],
            'kw_density'         => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Aim for a natural keyword density (about 0.5–2.5%). Too low looks off-topic; too high looks like keyword stuffing.', 'rankforge')],
            'kw_in_alt'          => ['page' => 'writing-a-good-focus-keyword',        'summary' => __('Add the keyword to a relevant image\'s alt text — it helps image search and accessibility.', 'rankforge')],
            'title_length'       => ['page' => 'writing-seo-titles-and-descriptions', 'summary' => __('Keep the SEO title about 50–60 characters so Google does not cut it off in results.', 'rankforge')],
            'title_number'       => ['page' => 'writing-seo-titles-and-descriptions', 'summary' => __('Numbers in titles (e.g. "7 ways…") tend to lift click-through rates.', 'rankforge')],
            'title_power_word'   => ['page' => 'writing-seo-titles-and-descriptions', 'summary' => __('A power or emotional word (ultimate, easy, proven…) makes the title more compelling.', 'rankforge')],
            'description_length' => ['page' => 'writing-seo-titles-and-descriptions', 'summary' => __('Keep the meta description about 140–160 characters — long enough to be useful, short enough to avoid truncation.', 'rankforge')],
            'content_length'     => ['page' => 'understanding-your-seo-score',         'summary' => __('Thorough content (600+ words) usually ranks better. Use "Expand with AI" to add depth without losing your text.', 'rankforge')],
            'internal_links'     => ['page' => 'understanding-your-seo-score',         'summary' => __('Link to your other relevant posts so readers and search crawlers can navigate your site.', 'rankforge')],
            'external_links'     => ['page' => 'understanding-your-seo-score',         'summary' => __('Linking to authoritative sources adds credibility. "Suggest links" proposes a few safe ones to review.', 'rankforge')],
            'media'              => ['page' => 'understanding-your-seo-score',         'summary' => __('Add at least one image or video — visual content improves engagement and rankings.', 'rankforge')],
            'has_headings'       => ['page' => 'understanding-your-seo-score',         'summary' => __('Break content into sections with H2/H3 headings for readability and structure.', 'rankforge')],
            'table_of_contents'  => ['page' => 'understanding-your-seo-score',         'summary' => __('A table of contents lets readers jump to sections and can earn rich results. "Insert ToC" builds one from your headings — no AI, no credits.', 'rankforge')],
            'flesch'             => ['page' => 'understanding-your-seo-score',         'summary' => __('Flesch reading ease measures how easy the text is to read (aim 60+). Shorter sentences and simpler words raise it.', 'rankforge')],
            'sentence_length'    => ['page' => 'understanding-your-seo-score',         'summary' => __('Keep sentences under about 20 words on average so the text stays easy to read.', 'rankforge')],
            'passive_voice'      => ['page' => 'understanding-your-seo-score',         'summary' => __('Prefer active voice ("we tested" over "it was tested") — it reads clearer and more directly.', 'rankforge')],
            'short_paragraphs'   => ['page' => 'understanding-your-seo-score',         'summary' => __('Break long paragraphs (150+ words) into smaller ones so the page is easier to scan.', 'rankforge')],
            'url_length'         => ['page' => 'understanding-your-seo-score',         'summary' => __('Keep URLs short and descriptive — very long slugs are harder to read and share.', 'rankforge')],
        ];
    }

    /**
 * The help map resolved for JS (renderIssuesList): code → {url, summary, cta}.
 * URLs come from Forge_Docs_Link so they track the docs host in one place.
 */
    public static function issue_help_localized() {
        $out = [];
        $has_docs = class_exists('Forge_Docs_Link');
        $cta = function_exists('__') ? __('Read the docs', 'rankforge') : 'Read the docs';
        foreach (self::issue_help_map() as $code => $h) {
            $out[$code] = [
                'url'     => $has_docs ? Forge_Docs_Link::url('rankforge', $h['page']) : '',
                'summary' => $h['summary'],
                'cta'     => $cta,
            ];
        }
        return $out;
    }

    /**
 * Server-rendered "?" tooltip for one issue (empty when no help/code).
 * Used by the metabox initial paint; JS re-renders the same control live.
 */
    public static function issue_help_tooltip($code, $title) {
        if (!$code || !class_exists('Forge_Docs_Link')) return '';
        $map = self::issue_help_map();
        if (empty($map[$code])) return '';
        return Forge_Docs_Link::tooltip('rankforge', $map[$code]['page'], $title, $map[$code]['summary']);
    }

    /**
 * Tiny AJAX endpoint: save a single focus keyword value outside the
 * post-save cycle. Used by the "Fix focus keyword" button so the
 * keyword persists immediately without the user clicking Update.
 */
    public function ajax_save_keyword() {
        check_ajax_referer('rankforge_nonce', 'nonce');
        $post_id = absint( wp_unslash($_POST['post_id'] ?? 0) );
        if (!$post_id || !current_user_can('edit_post', $post_id)) wp_send_json_error(['message' => 'forbidden'], 403);
        $keyword = sanitize_text_field(wp_unslash($_POST['keyword'] ?? ''));
        if ($keyword === '') wp_send_json_error(['message' => 'missing'], 400);
        update_post_meta($post_id, '_rankforge_focus_keyword', $keyword);
        wp_send_json_success(['keyword' => $keyword]);
    }

    /**
 * Persist meta title / description / keyword from Fix buttons so they
 * stick on page reload without the user clicking Update.
 */
    public function ajax_save_meta_field() {
        check_ajax_referer('rankforge_nonce', 'nonce');
        $post_id = absint( wp_unslash($_POST['post_id'] ?? 0) );
        if (!$post_id || !current_user_can('edit_post', $post_id)) wp_send_json_error(['message' => 'forbidden'], 403);
        $field   = sanitize_key($_POST['field'] ?? '');
        $value   = sanitize_text_field(wp_unslash($_POST['value'] ?? ''));
        $map = [
            'meta_title'       => '_rankforge_meta_title',
            'meta_description' => '_rankforge_meta_description',
            'keyword'          => '_rankforge_focus_keyword',
        ];
        if (!isset($map[$field])) wp_send_json_error(['message' => 'bad_request'], 400);
        update_post_meta($post_id, $map[$field], $value);
        wp_send_json_success(['field' => $field]);
    }

    /**
 * Round 3 (2026-04-28): Issues list live update.
 *
 * Re-runs the rule-based scorer with the user's un-saved title /
 * description / focus keyword so the Issues list under the metabox
 * stays in sync with the live score circle. Content-derived signals
 * (headings, images, internal links, readability) are taken from
 * what's currently saved as post_content — those don't change while
 * the user types in the SEO fields.
 *
 * Returns: { score, issues: [{severity, message}], count, fixable_count }
 *
 * Note: this endpoint is read-only; it does NOT cache the result to
 * `wp_rankforge_analysis`. The cached row is updated on save_post by
 * `auto_analyze`. We don't want a typing burst to thrash the cache.
 */
    public function ajax_get_issues() {
        check_ajax_referer('rankforge_nonce', 'nonce');
        $post_id = absint( wp_unslash($_POST['post_id'] ?? 0) );
        if (!$post_id || !current_user_can('edit_post', $post_id)) {
            wp_send_json_error(['message' => 'forbidden'], 403);
        }
        $post = get_post($post_id);
        if (!$post) {
            wp_send_json_error(['message' => 'no_post'], 404);
        }

        // Pull the un-saved values from the metabox.
        $meta_title = isset($_POST['meta_title'])
            ? sanitize_text_field(wp_unslash($_POST['meta_title']))
            : (string) get_post_meta($post_id, '_rankforge_meta_title', true);
        $meta_desc  = isset($_POST['meta_desc'])
            ? sanitize_textarea_field(wp_unslash($_POST['meta_desc']))
            : (string) get_post_meta($post_id, '_rankforge_meta_description', true);
        $keyword    = isset($_POST['keyword'])
            ? sanitize_text_field(wp_unslash($_POST['keyword']))
            : (string) get_post_meta($post_id, '_rankforge_focus_keyword', true);

        // Score the LIVE editor content the metabox sends (so the live score
        // reflects unsaved edits and equals the server's saved score — the
        // rubric lives only here, never re-implemented in JS). Fall back to the
        // saved/resolved content when the editor didn't send anything.
        $content_html = (isset($_POST['content_html']) && $_POST['content_html'] !== '')
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Unslashed HTML used for read-only analysis only (stripped via wp_strip_all_tags below); never stored or output.
            ? (string) wp_unslash($_POST['content_html'])
            : $this->resolve_content_html($post);
        $content_html = mb_substr($content_html, 0, 200000); // safety cap; analysis only, never output
        $content      = wp_strip_all_tags($content_html);

        // Image fallbacks for the "has media" test — catch images referenced
        // outside <img> tags (raw URLs, ACF attachments). The scorer counts
        // <img>/<video> in content_html itself.
        $has_image_url  = (bool) preg_match('~(https?:)?//[^\s"\'<>]+?\.(?:jpe?g|png|gif|webp|svg|avif)~i', $content_html);
        $has_image_meta = false;
        foreach (get_post_meta($post_id) as $mk => $mv) {
            if (strpos($mk, '_') === 0) continue;
            $val = is_array($mv) ? ($mv[0] ?? '') : $mv;
            if (is_numeric($val) && (int) $val > 0) {
                $mime = get_post_mime_type((int) $val);
                if ($mime && strpos($mime, 'image/') === 0) { $has_image_meta = true; break; }
            }
            if (is_string($val) && preg_match('/"(url|sizes|filename|mime_type)"/', $val) && preg_match('/image\//', $val)) {
                $has_image_meta = true; break;
            }
        }
        if (!$has_image_meta && function_exists('get_attached_media')) {
            $attached = get_attached_media('image', $post_id);
            if (!empty($attached)) $has_image_meta = true;
        }

        if (!class_exists('RANKFORGE_Scorer')) {
            require_once __DIR__ . '/class-seo-scorer.php';
        }

        $scored = RANKFORGE_Scorer::score([
            'post_title'    => $post->post_title,
            'meta_title'    => $meta_title ?: $post->post_title,
            'seo_title'     => $meta_title,
            'meta_desc'     => $meta_desc,
            'content'       => $content,
            'content_html'  => $content_html,
            'raw_content'   => $content_html,
            'keyword'       => $keyword,
            'is_english'    => self::is_english_post($post),
            'slug'          => $post->post_name,
            'has_image_url' => $has_image_url,
            'has_image_meta'=> $has_image_meta,
        ]);

        $issues = $scored['issues'];

        // Decide which issues get a Fix button (only the ones our AI endpoints
        // can actually resolve). Shared helper keeps this in sync with the
        // metabox renderer. RANKFORGE_AI_Client lives in rankforge-pro — on
        // Free-only sites no AI is configured and no Fix buttons render.
        $has_editable = $this->has_editable_content($post);
        $ai_configured = class_exists('RANKFORGE_AI_Client')
            ? RANKFORGE_AI_Client::instance()->is_configured()
            : false;
        $fixable_count = 0;
        $issues_out = [];
        $ideas_out  = []; // optional click-through ideas, split from problems
        $expand_cost = (class_exists('Forge_Credits_Widget') && method_exists('Forge_Credits_Widget', 'get_action_cost'))
            ? (int) Forge_Credits_Widget::get_action_cost('fix_issue') : 2;
        // ToC is offered only when there are ≥2 headings to link.
        $heading_count = preg_match_all('/<h[2-4][^>]*>/i', $content_html);
        foreach ($issues as $issue) {
            $msg = $issue['message'];
            $fixable = self::is_issue_ai_fixable($msg, $has_editable);
            // content-body rewrites open the review drawer (Before/After)
            // instead of applying in place. Carries the language-neutral scorer
            // code so the drawer can route the deterministic paragraph split.
            $content_fix = $ai_configured && $has_editable && self::is_issue_content_rewrite($msg, $has_editable);
            $expandable = $ai_configured && $has_editable && self::is_issue_expandable($msg);
            $toc_insertable      = $ai_configured && $has_editable && self::is_issue_toc($msg) && $heading_count >= 2;
            $external_suggestable = $ai_configured && $has_editable && self::is_issue_external($msg);
            $subhead_fixable     = $ai_configured && $has_editable && self::is_issue_subheading_keyword($msg) && $heading_count >= 1;
            $item = [
                'severity'    => $issue['severity'] ?? 'warning',
                'message'     => $msg,
                'code'        => $issue['code'] ?? '', // drives the per-issue help tooltip
                'points'      => (int) ($issue['points'] ?? 0),
                'fixable'     => $fixable && $ai_configured,
                'cost'        => self::fix_credit_cost($msg),
                'content_fix' => $content_fix,
                'content_code'=> $issue['code'] ?? '',
                'expandable'  => $expandable,
                'expand_cost' => $expand_cost,
                'toc'         => $toc_insertable,
                'external'    => $external_suggestable,
                'external_cost' => $expand_cost,
                'subhead'     => $subhead_fixable,
                'subhead_cost' => $expand_cost,
            ];
            // optional CTR heuristics go to "Optimization ideas", never
            // the problem count / Fix-all set.
            if (!empty($issue['optional'])) {
                $ideas_out[] = $item;
            } else {
                if ($ai_configured && $fixable) $fixable_count++;
                $issues_out[] = $item;
            }
        }

        wp_send_json_success([
            'score'         => (int) $scored['score'],
            'issues'        => $issues_out,
            'ideas'         => $ideas_out,
            'count'         => count($issues_out),
            'fixable_count' => $fixable_count,
            'ai_configured' => $ai_configured,
        ]);
    }

    public function add_meta_box() {
        $types = get_post_types(['public' => true], 'names');
        foreach ($types as $type) {
            if ($type === 'attachment') continue;
            // keep the SEO panel under the editor (like Yoast) instead of
            // cramming it into the narrow sidebar.
            add_meta_box('rankforge_seo_box', __('Rank Forge', 'rankforge'), [$this, 'render_meta_box'], $type, 'normal', 'high');
        }
    }

    public function render_meta_box($post) {
        // F14 final split (2026-04-21): body moved to class-seo-metabox.php.
        if (!class_exists("RANKFORGE_Metabox_View")) {
            require_once __DIR__ . "/class-seo-metabox.php";
        }
        RANKFORGE_Metabox_View::render($this, $post);
    }

    public function auto_analyze($post_id, $post) {
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
        if ($post->post_status === 'auto-draft') return;
        if (!current_user_can('edit_post', $post_id)) return;

        // Save meta fields. when the user clears the field in the
        // metabox and saves, delete the post meta row entirely rather than
        // storing an empty string. Empty post meta can persist as a stale
        // row that some later code path (analysis cache, A/B variant) may
        // still read back — deleting makes the cascade unambiguous.
        if (isset($_POST['rankforge_meta_nonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['rankforge_meta_nonce'])), 'rankforge_save_meta')) {
            if (isset($_POST['rankforge_focus_keyword'])) {
                $val = sanitize_text_field(wp_unslash($_POST['rankforge_focus_keyword']));
                if ($val === '') delete_post_meta($post_id, '_rankforge_focus_keyword');
                else             update_post_meta($post_id, '_rankforge_focus_keyword', $val);
            }
            if (isset($_POST['rankforge_meta_title'])) {
                $val = sanitize_text_field(wp_unslash($_POST['rankforge_meta_title']));
                if ($val === '') delete_post_meta($post_id, '_rankforge_meta_title');
                else             update_post_meta($post_id, '_rankforge_meta_title', $val);
            }
            if (isset($_POST['rankforge_meta_description'])) {
                $val = sanitize_textarea_field(wp_unslash($_POST['rankforge_meta_description']));
                if ($val === '') delete_post_meta($post_id, '_rankforge_meta_description');
                else             update_post_meta($post_id, '_rankforge_meta_description', $val);
            }
            if (isset($_POST['rankforge_canonical_url'])) {
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via esc_url_raw on the next line.
                $raw = trim((string) wp_unslash($_POST['rankforge_canonical_url']));
                $val = esc_url_raw($raw, ['http', 'https']);
                if ($val === '') {
                    delete_post_meta($post_id, '_rankforge_canonical_url');
                } else {
                    update_post_meta($post_id, '_rankforge_canonical_url', $val);
                }
            }

            // Save Schema Builder data
            if (isset($_POST['rankforge_manual_schema'])) {
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON unslashed then json_decode'd and re-encoded via wp_json_encode below; sanitize_text_field would corrupt the JSON.
                $schema_json = wp_unslash($_POST['rankforge_manual_schema']);
                if (empty($schema_json) || $schema_json === '{}') {
                    delete_post_meta($post_id, '_rankforge_manual_schema');
                } else {
                    $decoded = json_decode($schema_json, true);
                    if (is_array($decoded)) {
                        update_post_meta($post_id, '_rankforge_manual_schema', wp_json_encode($decoded));
                    }
                }
            }
            if (isset($_POST['rankforge_schema_type_override'])) {
                $schema_type = sanitize_text_field(wp_unslash($_POST['rankforge_schema_type_override']));
                if (!empty($schema_type) && $schema_type !== 'auto') {
                    update_post_meta($post_id, '_rankforge_schema_type', $schema_type);
                } else {
                    delete_post_meta($post_id, '_rankforge_schema_type');
                }
            }

            // Noindex/Nofollow
            update_post_meta($post_id, '_rankforge_noindex', !empty($_POST['rankforge_noindex']) ? '1' : '');
            update_post_meta($post_id, '_rankforge_nofollow', !empty($_POST['rankforge_nofollow']) ? '1' : '');

            // Per-post Social title / description overrides.
            foreach ([
                'rankforge_og_title'           => '_rankforge_og_title',
                'rankforge_og_description'     => '_rankforge_og_description',
                'rankforge_twitter_title'      => '_rankforge_twitter_title',
                'rankforge_twitter_description'=> '_rankforge_twitter_description',
            ] as $field => $meta_key) {
                if (!isset($_POST[$field])) continue;
                $val = sanitize_text_field(wp_unslash($_POST[$field]));
                if ($val === '') {
                    delete_post_meta($post_id, $meta_key);
                } else {
                    update_post_meta($post_id, $meta_key, $val);
                }
            }

            // Social Media Image (per-post og:image override)
            if (isset($_POST['rankforge_og_image_id'])) {
                $og_id = (int) $_POST['rankforge_og_image_id'];
                if ($og_id > 0) {
                    update_post_meta($post_id, '_rankforge_og_image_id', $og_id);
                    $url = wp_get_attachment_image_url($og_id, 'full');
                    if ($url) update_post_meta($post_id, '_rankforge_og_image', esc_url_raw($url));
                } else {
                    delete_post_meta($post_id, '_rankforge_og_image_id');
                    delete_post_meta($post_id, '_rankforge_og_image');
                }
            }

            RANKFORGE_Video_Seo::instance()->save_video_meta($post_id);
        }

        // Auto-analyze runs rule-based ONLY (no AI credit cost).
        // Users can trigger AI analysis explicitly via the metabox button.
        $this->analyze($post_id, false);
    }

    /**
 * Run full SEO analysis on a post.
 *
 * @param int $post_id
 * @param bool $with_ai If false, skip the paid AI analysis call and
 * return rule-based score only. Defaults to true
 * so explicit re-analyze calls still use AI.
 * @param bool $persist If false, return the result without writing the
 * analysis cache. Used by the metabox to seed the
 * score + Issues list on posts with no cached row
 * yet without a write on a render path.
 */
    /**
 * Resolve a post's full effective HTML content, pulling from page
 * builders / ACF flexible content when `post_content` is empty.
 * Returned string retains HTML tags so image/link regex checks work
 * on the text a visitor actually sees.
 */
    public function resolve_content_html($post) {
        $html = (string) $post->post_content;
        if (trim(wp_strip_all_tags($html)) === '') {
            $filtered = (string) apply_filters('the_content', $html); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            if (trim(wp_strip_all_tags($filtered)) !== '') $html = $filtered;
        }
        if (trim(wp_strip_all_tags($html)) === '') {
            foreach (['_elementor_data', '_fl_builder_data', 'et_pb_old_content', 'ct_builder_shortcodes'] as $mk) {
                $raw = get_post_meta($post->ID, $mk, true);
                if (!empty($raw)) { $html = is_array($raw) ? wp_json_encode($raw) : (string) $raw; break; }
            }
        }
        if (trim(wp_strip_all_tags($html)) === '') {
            // ACF Flexible / repeater sweep: collect every long string
            // postmeta value (editor / content blobs).
            $chunks = [];
            $all = get_post_meta($post->ID);
            foreach ($all as $k => $v) {
                if (strpos($k, '_') === 0) continue;
                $val = is_array($v) ? ($v[0] ?? '') : $v;
                if (!is_string($val) || mb_strlen($val) < 40) continue;
                if (preg_match('/^[aOs]:\d+:/', $val)) continue;
                $chunks[] = $val;
            }
            if ($chunks) $html = implode("\n\n", array_slice($chunks, 0, 20));
        }
        return $html;
    }

    /**
 * True when the post has a text-editor field the AI can actually write
 * into — i.e. non-empty wp_posts.post_content. Page-builder-only posts
 * (Elementor, ACF Flexible) have no target for "insert internal link"
 * or "rewrite content" style fixes, so those Fix buttons must be hidden.
 */
    public function has_editable_content($post) {
        return trim(wp_strip_all_tags((string) $post->post_content)) !== '';
    }

    public function analyze($post_id, $with_ai = true, $persist = true) {
        $post = get_post($post_id);
        if (!$post) return null;

        // Full HTML (for img/link regex) + plain text (for word count etc.)
        $content_html = $this->resolve_content_html($post);
        $content = wp_strip_all_tags($content_html);
        $keyword = get_post_meta($post_id, '_rankforge_focus_keyword', true);
        $seo_title = (string) get_post_meta($post_id, '_rankforge_meta_title', true); // raw — '' when unset
        $meta_title = $seo_title !== '' ? $seo_title : $post->post_title;             // effective (cache + keyword check)
        $meta_desc = get_post_meta($post_id, '_rankforge_meta_description', true);

        // The SEO score + Issues list are ALWAYS rule-based: deterministic,
        // reproducible, and every point is explainable + fixable. AI never
        // computes the score or the issues (it used to overwrite the cache
        // with an opaque score and non-actionable "issues" like "content is
        // informative", which made the number jump around and Fix buttons
        // no-op). AI's role is to *raise* the score — generate keyword/title/
        // description and fix issues — not to assess it. The $with_ai param is
        // kept for signature back-compat but no longer affects scoring.

        // --- Rule-based analysis (pure scoring in RANKFORGE_Scorer) ---
        // Image fallbacks for the "has media" test — catch images referenced
        // outside <img> tags (raw URLs, ACF attachments). The scorer counts
        // <img>/<video> in content_html itself.
        $has_image_url = (bool) preg_match('~(https?:)?//[^\s"\'<>]+?\.(?:jpe?g|png|gif|webp|svg|avif)~i', $content_html);
        $has_image_meta = false;
        foreach (get_post_meta($post_id) as $mk => $mv) {
            if (strpos($mk, '_') === 0) continue;
            $val = is_array($mv) ? ($mv[0] ?? '') : $mv;
            if (is_numeric($val) && (int) $val > 0) {
                $mime = get_post_mime_type((int) $val);
                if ($mime && strpos($mime, 'image/') === 0) { $has_image_meta = true; break; }
            }
            if (is_string($val) && preg_match('/"(url|sizes|filename|mime_type)"/', $val) && preg_match('/image\//', $val)) {
                $has_image_meta = true; break;
            }
        }
        if (!$has_image_meta && function_exists('get_attached_media')) {
            $attached = get_attached_media('image', $post_id);
            if (!empty($attached)) $has_image_meta = true;
        }

        // Delegate to the pure scorer — no WP calls inside.
        $scored = RANKFORGE_Scorer::score([
            'post_title'    => $post->post_title,
            'meta_title'    => $meta_title,
            'seo_title'     => $seo_title,
            'meta_desc'     => $meta_desc,
            'content'       => $content,
            'content_html'  => $content_html,
            'raw_content'   => $post->post_content,
            'keyword'       => $keyword,
            'is_english'    => self::is_english_post($post),
            'slug'          => $post->post_name,
            'has_image_url' => $has_image_url,
            'has_image_meta'=> $has_image_meta,
        ]);
        $score       = $scored['score'];
        $issues      = $scored['issues'];
        $suggestions = $scored['suggestions'];

        // Cache result (skip when called read-only, e.g. metabox seed)
        if ($persist) $this->save_cache($post_id, $score, $issues, $suggestions, $meta_title, $meta_desc);

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

    /**
 * Build a snapshot of everything the JS live scorer needs about
 * the stored content. We intentionally only include content-derived
 * metrics here; the user-editable meta fields (keyword / meta title /
 * meta description) are read from the DOM at score time.
 */
    public function prepare_live_scoring_context($post) {
        $raw_content    = $post->post_content;
        $content        = wp_strip_all_tags($raw_content);
        $word_count     = str_word_count($content);
        $first_para     = mb_substr($content, 0, 300);
        $title          = $post->post_title;

        // Headings
        preg_match_all('/<h([1-6])[^>]*>/i', $raw_content, $h_m);
        $has_headings = !empty($h_m[0]);

        // Images + missing alt
        preg_match_all('/<img[^>]+>/i', $raw_content, $img_m);
        $image_count = count($img_m[0]);
        $missing_alt = 0;
        foreach ($img_m[0] as $img) {
            if (!preg_match('/alt\s*=\s*["\'][^"\']+["\']/', $img)) $missing_alt++;
        }

        // Internal links
        preg_match_all('/<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>/i', $raw_content, $a_m);
        $home = home_url();
        $internal = 0;
        foreach ($a_m[1] ?? [] as $href) {
            if (strpos($href, $home) !== false || (strpos($href, 'http') !== 0 && strpos($href, '//') !== 0)) $internal++;
        }

        // Readability-ish stats
        $sentences = preg_split('/[.!?]+/u', $content);
        $sentences = array_filter($sentences, function ($s) { return str_word_count(trim($s)) > 2; });
        $sentence_count = count($sentences);
        $avg_sentence   = $sentence_count > 0 ? $word_count / $sentence_count : 0;

        // Flesch + passive are English-only — null/0 for other languages so
        // the live JS doesn't penalize Spanish/Russian/etc. posts.
        $is_english_content = self::is_english_post($post);

        $flesch = null;
        if ($is_english_content && $sentence_count > 0 && $word_count > 0) {
            $syl = 0;
            foreach (preg_split('/\s+/u', $content) as $w) {
                $w = mb_strtolower(trim($w));
                if ($w === '') continue;
                if (mb_strlen($w) <= 3) { $syl += 1; continue; }
                $syl += max(1, preg_match_all('/[aeiouy]+/i', $w));
            }
            $flesch = 206.835 - (1.015 * ($word_count / $sentence_count)) - (84.6 * ($syl / max(1, $word_count)));
            $flesch = (int) max(0, min(100, round($flesch)));
        }

        $paragraphs = preg_split('/<\/p>|<br\s*\/?>\s*<br\s*\/?>|\n\n/i', $post->post_content);
        $long_paras = 0;
        foreach ($paragraphs as $para) {
            if (str_word_count(wp_strip_all_tags($para)) > 150) $long_paras++;
        }

        $passive_pct = 0;
        if ($is_english_content && $sentence_count > 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;
        }

        // R3 (2026-04-28): Schema Builder HowTo preview. Run the same
        // extraction logic the renderer would apply if HowTo was the
        // emitted schema type. The Schema Builder JS uses this list to
        // warn / notice / confirm depending on count; we don't gate save,
        // only inform.
        $howto_steps = [];
        if (class_exists('RANKFORGE_Schema')) {
            $howto_steps = RANKFORGE_Schema::instance()->preview_howto_steps($post->post_content);
        }
        $howto_step_names = [];
        foreach ($howto_steps as $st) {
            if (!empty($st['name'])) $howto_step_names[] = (string) $st['name'];
        }

        return [
            'post_id'           => (int) $post->ID,
            'post_title'        => $title,
            'content_lc'        => mb_strtolower($content),
            'first_para_lc'     => mb_strtolower($first_para),
            'word_count'        => (int) $word_count,
            'has_headings'      => (bool) $has_headings,
            'image_count'       => (int) $image_count,
            'missing_alt_count' => (int) $missing_alt,
            'internal_links'    => (int) $internal,
            'avg_sentence'      => round($avg_sentence, 1),
            'sentence_count'    => (int) $sentence_count,
            'flesch'            => $flesch,
            'long_paragraphs'   => (int) $long_paras,
            'passive_pct'       => round($passive_pct, 1),
            // R3 F5: HowTo extraction preview for the Schema Builder UI.
            // Frozen at page load — the post body isn't editable from the
            // SEO metabox, so the steps don't change without a reload.
            'howto_step_count'  => count($howto_step_names),
            'howto_step_names'  => $howto_step_names,
        ];
    }

    public function get_cached($post_id) {
        global $wpdb;
        // R2-N2 (2026-04-28): table-exists guard. If activation hook didn't
        // run, the post-edit metabox render shouldn't crash MySQL with a
        // bogus SELECT — return null and let the metabox fall back to
        // "no cached analysis yet" (its existing nullable code path).
        $table = $wpdb->prefix . 'rankforge_analysis';
        if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
            return null;
        }
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        return $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$table} WHERE post_id = %d", $post_id
        ));
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }


    /**
 * Render Schema Builder section in the SEO metabox.
 * F14 (2026-04-21): body extracted to class-seo-schema-builder.php.
 * This thin delegator preserves the original public API so any
 * caller still hitting \$analyzer->render_schema_builder(\$post)
 * keeps working; new callers should call the view directly.
 */
    public function render_schema_builder($post) {
        if (!class_exists("RANKFORGE_Schema_Builder_View")) {
            require_once __DIR__ . "/class-seo-schema-builder.php";
        }
        RANKFORGE_Schema_Builder_View::render($post);
    }

    private function save_cache($post_id, $score, $issues, $suggestions, $meta_title, $meta_desc) {
        global $wpdb;
        $table = $wpdb->prefix . 'rankforge_analysis';

        $data = [
            'post_id' => $post_id,
            'score' => $score,
            'issues' => wp_json_encode($issues),
            'suggestions' => wp_json_encode($suggestions),
            'meta_title' => $meta_title,
            'meta_description' => $meta_desc,
            'analyzed_at' => current_time('mysql'),
        ];

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $existing = $wpdb->get_var($wpdb->prepare("SELECT id FROM $table WHERE post_id = %d", $post_id));
        if ($existing) {
            $wpdb->update($table, $data, ['id' => $existing]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
        } else {
            $wpdb->insert($table, $data); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
        }
    }
}
