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

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

    private function __construct() {
        add_action('rest_api_init', [$this, 'register_routes']);
    }

    private static function credits_exhausted_response($error) {
        $data = $error->get_error_data();
        return [
            'success'    => false,
            'error'      => 'credits_exhausted',
            'message'    => $error->get_error_message(),
            'remaining'  => $data['remaining'] ?? 0,
            'reset_date' => $data['reset_date'] ?? '',
        ];
    }

    /**
 * SF-QA-001: PRO plan guard for all AI endpoints.
 *
 * Returns a WP_Error (403) if the current user is not on a PRO/trial plan.
 * Call this BEFORE the $ai->is_configured check so Free users who happen
 * to have an API key cannot bypass the paywall by hitting the endpoint
 * directly (defense-in-depth — UI hides buttons too, see class-seo-metabox.php).
 */
    private static function require_ai_pro() {
        if ( ! RANKFORGE_License::can( 'ai_features' ) ) {
            return new WP_Error(
                'pro_required',
                __( 'This feature requires a Rank Forge Pro license.', 'rankforge' ),
                [ 'status' => 403 ]
            );
        }
        return null;
    }

    /**
 * Code-presence check, not a licence check. Broken-link SCANNING is part of
 * this plugin and free; what this guards is the AI-backed per-post link
 * check, whose client ships only in the separate Rank Forge Pro plugin.
 */
    private static function require_internal_links_pro() {
        if ( ! class_exists( 'RANKFORGE_AI_Client' ) ) {
            return new WP_Error(
                'feature_not_available',
                __( 'AI link checking is provided by the separate Rank Forge Pro plugin, which is not installed.', 'rankforge' ),
                [ 'status' => 404 ]
            );
        }
        return null;
    }

    /**
 * Per-post BOLA guard. permission_callback only checks the generic
 * `edit_posts` cap — every handler that mutates or reads a specific
 * post must additionally verify the caller can edit THAT post, otherwise
 * any author can rewrite SEO meta / post_content of any other author's
 * post by passing its ID.
 *
 * Returns null on success, WP_Error on bad input / not-found / denied.
 */
    private static function require_can_edit_post($post_id) {
        $post_id = (int) $post_id;
        if ($post_id <= 0) {
            return new WP_Error('bad_request', __('Invalid post_id.', 'rankforge'), ['status' => 400]);
        }
        if (!get_post($post_id)) {
            return new WP_Error('not_found', __('Post not found.', 'rankforge'), ['status' => 404]);
        }
        if (!current_user_can('edit_post', $post_id)) {
            return new WP_Error('forbidden', __('You are not allowed to edit this post.', 'rankforge'), ['status' => 403]);
        }
        return null;
    }

    public function register_routes() {
        $ns = 'rankforge/v1';
        $auth = function () { return current_user_can('edit_posts'); };
        // Stronger gate for credit-spending generation NOT tied to editing a
        // specific post (site-wide local SEO, topic briefs): require an
        // editor-level capability so low-trust authors/contributors can't drain
        // shared account AI credits. Relax to $auth if authors should generate.
        $auth_manage = function () { return current_user_can('edit_others_posts'); };
        // Administrator gate for site-wide, non-post-scoped reads whose data is
        // only surfaced on manage_options admin pages (e.g. the dashboard
        // aggregate). Keeps low-trust editors from harvesting site-wide stats
        // via REST that the admin UI itself hides from them.
        $admin = function () { return current_user_can('manage_options'); };

        register_rest_route($ns, '/analyze/(?P<post_id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'analyze'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/score/(?P<post_id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'score'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/meta/generate', ['methods' => 'POST', 'callback' => [$this, 'generate_meta'], 'permission_callback' => $auth]);
        // drawer preview + apply for AI meta fixes. The /meta/preview
        // endpoint spends credits to generate an AI suggestion but does NOT
        // persist it — the drawer shows it for the user to review/edit.
        // /meta/apply persists whatever text the user confirmed.
        register_rest_route($ns, '/meta/preview', ['methods' => 'POST', 'callback' => [$this, 'preview_meta'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/meta/apply', ['methods' => 'POST', 'callback' => [$this, 'apply_meta'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/links/suggest/(?P<post_id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'suggest_links'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/schema/(?P<post_id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'get_schema'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/dashboard', ['methods' => 'GET', 'callback' => [$this, 'dashboard'], 'permission_callback' => $admin]);
        register_rest_route($ns, '/fix/(?P<post_id>\d+)', ['methods' => 'POST', 'callback' => [$this, 'fix_issue'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/fix/bulk', ['methods' => 'POST', 'callback' => [$this, 'fix_issue_bulk'], 'permission_callback' => $auth]);
        // v3: drawer support for non-meta fixes (thin_content,
        // duplicate_titles, no_internal_links, etc.). Two-phase: preview
        // generates AI rewrite without saving (spends credits), apply
        // persists the user-reviewed value.
        register_rest_route($ns, '/fix/preview', ['methods' => 'POST', 'callback' => [$this, 'preview_fix'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/fix/apply',   ['methods' => 'POST', 'callback' => [$this, 'apply_fix'],   'permission_callback' => $auth]);
        register_rest_route($ns, '/links/auto-insert/(?P<post_id>\d+)', ['methods' => 'POST', 'callback' => [$this, 'auto_insert_links'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/ai-readiness/(?P<post_id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'ai_readiness'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/brief/generate', ['methods' => 'POST', 'callback' => [$this, 'generate_brief'], 'permission_callback' => $auth_manage]);
        // brief history — per-user persisted briefs so users can revisit
        // paid-for results without re-generating. Free to read.
        register_rest_route($ns, '/brief/history', ['methods' => 'GET', 'callback' => [$this, 'list_briefs'], 'permission_callback' => $auth]);
        // fix: ids come from `substr(wp_generate_uuid4, 0, 12)` which
        // keeps the first UUID hyphen (`xxxxxxxx-xxx`). Regex must allow `-`
        // or the route 404s on every View/Delete click.
        register_rest_route($ns, '/brief/history/(?P<id>[a-z0-9\-]+)', [
            'methods' => 'GET',
            'callback' => [$this, 'get_brief'],
            'permission_callback' => $auth,
        ]);
        register_rest_route($ns, '/brief/history/(?P<id>[a-z0-9\-]+)', [
            'methods' => 'DELETE',
            'callback' => [$this, 'delete_brief'],
            'permission_callback' => $auth,
        ]);
        register_rest_route($ns, '/local/generate', ['methods' => 'POST', 'callback' => [$this, 'generate_local_seo'], 'permission_callback' => $auth_manage]);
        register_rest_route($ns, '/links/broken/(?P<post_id>\d+)', ['methods' => 'GET', 'callback' => [$this, 'check_broken_links'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/schema/detect/(?P<post_id>\d+)', ['methods' => 'POST', 'callback' => [$this, 'detect_schema'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/optimize/(?P<post_id>\d+)', ['methods' => 'POST', 'callback' => [$this, 'optimize_content'], 'permission_callback' => $auth]);
        register_rest_route($ns, '/optimize/apply/(?P<post_id>\d+)', ['methods' => 'POST', 'callback' => [$this, 'apply_optimization'], 'permission_callback' => $auth]);
    }

    public function analyze($req) {
        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $result = RANKFORGE_Analyzer::instance()->analyze($post_id);
        return rest_ensure_response($result);
    }

    public function score($req) {
        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $cached = RANKFORGE_Analyzer::instance()->get_cached($post_id);
        return rest_ensure_response(['score' => $cached ? (int) $cached->score : 0]);
    }

    public function generate_meta($req) {
        // SF-QA-001: PRO gate — must come before the AI-configured check.
        if ($err = self::require_ai_pro()) return $err;

        $params = $req->get_json_params();
        $post_id = (int) ($params['post_id'] ?? $req->get_param('post_id') ?? 0);
        // Optional: 'title' | 'description' | 'both' (default 'both' for back-compat).
        // The metabox per-field "AI" buttons should pass the matching type so
        // they cost 1 credit instead of 2 (skipping the unrequested half).
        $type = $params['type'] ?? $req->get_param('type') ?? 'both';
        // When a Fix button drives this, it passes the issue message so a
        // title-quality fix (add a number / power word / keyword near start)
        // can do a targeted rewrite instead of a generic regeneration.
        $issue = isset($params['issue']) ? (string) $params['issue'] : '';

        if ($err = self::require_can_edit_post($post_id)) return $err;

        $title = null;
        $description = null;
        $keyword = null;

        if ($type === 'title' || $type === 'both') {
            if ($type === 'title' && $issue !== '' && preg_match('/add a number|power or emotional|near the start/i', $issue)) {
                $title = $this->rewrite_strong_title($post_id);
            } else {
                $title = RANKFORGE_Meta_Generator::instance()->generate_title($post_id);
            }
            if (is_wp_error($title) && $title->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($title));
            }
            if (is_wp_error($title)) $title = null;
        }

        if ($type === 'description' || $type === 'both') {
            $description = RANKFORGE_Meta_Generator::instance()->generate_description($post_id);
            if (is_wp_error($description) && $description->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($description));
            }
        }

        if ($type === 'keyword') {
            $post = get_post($post_id);
            if (!$post) {
                return new WP_Error('no_post', 'Post not found', ['status' => 404]);
            }
            $content = wp_strip_all_tags(apply_filters('the_content', $post->post_content)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            if (trim($content) === '') $content = wp_strip_all_tags($post->post_content);
            $content = mb_substr($content, 0, 4000);

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

            // The focus keyword must be built from words that actually appear in
            // the article — otherwise the rule-based "keyword in content / first
            // paragraph" checks can never pass and the generated keyword does
            // nothing for the score. Ask the model for a content-grounded phrase,
            // then enforce it: if the model strays, derive the keyword from the
            // article's own most frequent meaningful words.
            // Dedicated generate_keyword action: real role:system + server-side
            // content-grounding validation (every keyword word must be in the
            // article). Plugin-side grounding/density guards below stay as a net.
            $resp = RANKFORGE_Forge_API::instance()->ai_generate_keyword($content);
            if (is_wp_error($resp)) {
                if ($resp->get_error_code() === 'credits_exhausted') {
                    return rest_ensure_response(self::credits_exhausted_response($resp));
                }
                return rest_ensure_response(['title' => null, 'description' => null, 'keyword' => null, 'error' => $resp->get_error_message()]);
            }
            $kw = is_array($resp) ? ($resp['result'] ?? '') : (string) $resp;
            $keyword = trim(wp_strip_all_tags((string) $kw));
            $keyword = trim($keyword, " \t\n\r\0\x0B\"'«»“”‘’.?!,:;()[]{}");

            // Enforce content-grounding: if the model returned nothing or used
            // words not in the article, fall back to the article's top words so
            // the keyword always passes the in-content check.
            if ($keyword === '' || !RANKFORGE_Scorer::keyword_present($keyword, $content)) {
                $derived = self::derive_keyword_from_content($content);
                if ($derived !== '') $keyword = $derived;
            }

            // Density consistency: a keyword we *generate* must satisfy our own
            // density rule (0.5–3%). Otherwise we burn a credit and then flag it
            // "density low/too high" — telling the user the keyword we sold them
            // is bad. If the chosen phrase falls outside the band, prefer the
            // content-derived keyword when it lands in band.
            $wc = RANKFORGE_Scorer::word_count($content);
            $in_band = function ($kw) use ($content, $wc) {
                $d = RANKFORGE_Scorer::keyword_density($kw, $content, $wc);
                return ($d >= 0.5 && $d <= 3.0);
            };
            if ($keyword === '' || !$in_band($keyword)) {
                $derived = self::derive_keyword_from_content($content, $wc);
                if ($derived !== '' && ($in_band($derived) || $keyword === '')) {
                    $keyword = $derived;
                }
            }
        }

        // a paid keyword+title+description generation must not
        // immediately re-trip "Focus keyword not in title/description". When the
        // Worker couldn't fit the keyword (it returns success:false and we fell
        // back to a keyword-less title) — or any path left the keyword out —
        // weave it in deterministically so the very next analyze shows those
        // checks passing. No-op when the keyword is already present (AI-success
        // case) or when no focus keyword is set on the post.
        $kw_for_meta = trim((string) get_post_meta($post_id, '_rankforge_focus_keyword', true));
        if ($kw_for_meta !== '') {
            if (is_string($title) && $title !== '') {
                $title = RANKFORGE_Meta_Generator::ensure_keyword_in_title($title, $kw_for_meta);
            }
            if (is_string($description) && $description !== '') {
                $description = RANKFORGE_Meta_Generator::ensure_keyword_in_description($description, $kw_for_meta);
            }
        }

        // persist generated values to post meta immediately so they
        // survive actions that trigger a page reload (Auto-Link, Fix,
        // switching tabs, etc.) without the user having to hit "Update" on
        // the post first. Previously we only returned the strings and the
        // metabox filled the inputs client-side — on reload they were gone
        // and users blamed Auto-Link for "wiping" the SEO meta.
        $meta_changed = false;
        if ( is_string( $title ) && $title !== '' ) {
            update_post_meta( $post_id, '_rankforge_meta_title', sanitize_text_field( $title ) );
            $meta_changed = true;
        }
        if ( is_string( $description ) && $description !== '' ) {
            update_post_meta( $post_id, '_rankforge_meta_description', sanitize_textarea_field( $description ) );
            $meta_changed = true;
        }
        if ( is_string( $keyword ) && $keyword !== '' ) {
            update_post_meta( $post_id, '_rankforge_focus_keyword', sanitize_text_field( $keyword ) );
            $meta_changed = true;
        }

        // Recompute the cached SEO analysis so issues like "Focus keyword not
        // in title/description" disappear on reload. Otherwise the metabox
        // reads stale issues from wp_rankforge_analysis and the user keeps
        // seeing the same Fix target after clicking Fix. Run WITHOUT AI to
        // avoid burning a credit on every metadata edit — the rule-based
        // analyzer evaluates the exact conditions that produce those issues.
        if ( $meta_changed ) {
            RANKFORGE_Analyzer::instance()->analyze( $post_id, false );
        }

        return rest_ensure_response([
            'title' => $title,
            'description' => $description,
            'keyword' => $keyword,
        ]);
    }

    /**
 * Build a focus keyword from an article's own most frequent meaningful
 * words. Used as the grounded fallback when the AI keyword isn't present
 * in the content (#4) — the returned words are guaranteed to appear in the
 * text, so the rule-based in-content / first-paragraph checks pass.
 */
    private static function derive_keyword_from_content($content, $word_count = null) {
        $text = mb_strtolower(wp_strip_all_tags((string) $content));
        preg_match_all('/[\p{L}]{4,}/u', $text, $m);
        $words = $m[0] ?? [];
        if (!$words) return '';
        if (!class_exists('RANKFORGE_Scorer')) require_once __DIR__ . '/class-seo-scorer.php';
        $wc = ($word_count === null) ? RANKFORGE_Scorer::word_count($content) : (int) $word_count;
        static $stop = null;
        if ($stop === null) {
            $stop = array_flip(['this','that','with','from','your','their','about','have','will','what','when','which','there','these','those','they','them','then','than','into','over','under','also','because','while','being','been','were','does','done','some','such','only','very','just','more','most','much','many','each','every','other','another','should','would','could','after','before','still','even','make','makes','made','here','where','whom','onto','upon','like']);
        }
        $freq = [];
        foreach ($words as $w) {
            if (isset($stop[$w])) continue;
            $freq[$w] = ($freq[$w] ?? 0) + 1;
        }
        if (!$freq) return '';
        arsort($freq);
        // Prefer words that are NOT over-dense on their own (≤3%), so the pair
        // we build stays inside our 0.5–3% band instead of tripping "too high".
        // Fall back to the raw top words if filtering leaves too few (short post).
        $in_range = [];
        if ($wc > 0) {
            foreach ($freq as $w => $cnt) {
                if (($cnt / $wc) * 100 <= 3.0) $in_range[] = $w;
            }
        }
        $pick = (count($in_range) >= 2) ? $in_range : array_keys($freq);
        // Top-2 form a natural focus keyword whose density is capped by the
        // rarer word — landing in-band in any real article.
        return implode(' ', array_slice($pick, 0, 2));
    }

    /**
 * Targeted "strong title" rewrite for title-quality fixes (add a number /
 * power word / keyword near the start). A precise ai_custom prompt so the
 * result reliably satisfies those checks, unlike a generic regeneration.
 */
    private function rewrite_strong_title($post_id) {
        $post = get_post($post_id);
        if (!$post) return null;
        $keyword = trim((string) get_post_meta($post_id, '_rankforge_focus_keyword', true));
        $content = mb_substr(wp_strip_all_tags((string) apply_filters('the_content', $post->post_content)), 0, 800); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility

        // Dedicated generate_title with style=strong: real role:system + a
        // validator that enforces 50-60 chars, the keyword, a number and no
        // ellipsis (retried server-side). No client-side truncation.
        $resp = RANKFORGE_Forge_API::instance()->ai_generate_title($post->post_title, $content, $keyword, '', 'strong');
        if (is_wp_error($resp)) return $resp;
        if (empty($resp['success']) || empty($resp['result'])) return null;
        $t = is_array($resp['result']) ? '' : (string) $resp['result'];
        $t = RANKFORGE_Meta_Generator::sanitize_meta_text($t);
        return $t !== '' ? $t : null;
    }

    /**
 * Expand the post content to ~600+ words for the content-length fix. Adds
 * depth while preserving the existing text, facts, structure and language.
 * Returns expanded HTML (NOT persisted — the drawer previews it first).
 */
    private function expand_content_suggestion($post_id) {
        $post = get_post($post_id);
        if (!$post) return new WP_Error('no_post', 'Post not found');
        $current = (string) $post->post_content;
        if (trim(wp_strip_all_tags($current)) === '') {
            return new WP_Error('no_content', __('There is no editable content to expand.', 'rankforge'));
        }
        $keyword = trim((string) get_post_meta($post_id, '_rankforge_focus_keyword', true));

        // Dedicated expand_content action (real role:system + server-side
        // word-count validation/retry, ≥600). The plugin still splits long
        // paragraphs and enforces readability on the result below.
        $resp = RANKFORGE_Forge_API::instance()->ai_expand_content($current, $keyword);
        if (is_wp_error($resp)) return $resp;
        $out = is_array($resp) ? ($resp['result'] ?? '') : (string) $resp;
        $out = trim((string) $out);
        $out = preg_replace('/^```html?\s*/i', '', $out);
        $out = preg_replace('/\s*```$/', '', $out);
        // Guarantee our own "no paragraph over 150 words" rule even if the model
        // ignores the instruction — otherwise we charge a credit to expand and
        // then immediately flag the result for long paragraphs.
        $out = self::split_long_paragraphs(trim($out));
        // And enforce our readability rules (Flesch / sentence length / passive)
        // so the expansion can't trip "hard to read" the moment it lands.
        return $this->enforce_readability($out, $post_id);
    }

    /**
 * Make AI-generated content obey our readability rules. The expand prompt is
 * strict, but Flesch/sentence/passive aren't deterministic, so if the output
 * still fails for an English post we run ONE corrective "simplify" pass and
 * keep it only when it actually improves Flesch (never regress). Non-English
 * posts auto-pass these checks in the scorer, so we skip the extra call.
 */
    private function enforce_readability($html, $post_id) {
        if (trim((string) $html) === '') return $html;
        if (!class_exists('RANKFORGE_Scorer')) require_once __DIR__ . '/class-seo-scorer.php';
        $post = get_post($post_id);
        $is_english = (class_exists('RANKFORGE_Analyzer') && $post) ? RANKFORGE_Analyzer::is_english_post($post) : true;
        if (!$is_english) return $html;
        $s = RANKFORGE_Scorer::readability_stats($html, true);
        $fails = (($s['flesch'] !== null && $s['flesch'] < 50) || $s['avg_sentence'] > 20 || $s['passive_pct'] > 10);
        if (!$fails) return $html;

        $resp = RANKFORGE_Forge_API::instance()->ai_simplify_content($html, $is_english);
        if (is_wp_error($resp)) return $html; // keep the first version on error
        $simpler = is_array($resp) ? ($resp['result'] ?? '') : (string) $resp;
        $simpler = trim((string) $simpler);
        $simpler = preg_replace('/^```html?\s*/i', '', $simpler);
        $simpler = preg_replace('/\s*```$/', '', $simpler);
        $simpler = self::split_long_paragraphs(trim($simpler));
        if ($simpler === '') return $html;
        // Accept only if Flesch genuinely improved — never hand back worse text.
        $after = RANKFORGE_Scorer::readability_stats($simpler, true);
        return (($after['flesch'] ?? 0) >= ($s['flesch'] ?? 0)) ? $simpler : $html;
    }

    /**
 * Guarantee AI-expanded prose obeys our "no paragraph over 150 words" rule
 * (RANKFORGE_Scorer short_paragraphs): split any oversize <p> into several
 * at sentence boundaries. We require whitespace AFTER the sentence
 * punctuation so URLs ("site.com"), decimals ("3.5") and abbreviations
 * aren't split. A single >150-word run-on sentence (no safe split point)
 * is left intact rather than mangled.
 */
    private static function split_long_paragraphs($html, $max = 150, $target = 110) {
        if (!class_exists('RANKFORGE_Scorer')) {
            require_once __DIR__ . '/class-seo-scorer.php';
        }
        return preg_replace_callback('/<p\b([^>]*)>(.*?)<\/p>/is', function ($m) use ($max, $target) {
            $attrs = $m[1];
            $inner = trim($m[2]);
            $plain = trim(preg_replace('/\s+/u', ' ', wp_strip_all_tags($inner)));
            if (RANKFORGE_Scorer::word_count($plain) <= $max) return $m[0];
            $sentences = preg_split('/(?<=[.!?…。！？])\s+/u', $inner, -1, PREG_SPLIT_NO_EMPTY);
            if (count($sentences) < 2) return $m[0]; // one giant sentence — don't mangle
            $chunks = [];
            $buf = '';
            $buf_words = 0;
            foreach ($sentences as $s) {
                $w = RANKFORGE_Scorer::word_count(wp_strip_all_tags($s));
                if ($buf !== '' && $buf_words + $w > $target) {
                    $chunks[] = $buf;
                    $buf = $s; $buf_words = $w;
                } else {
                    $buf = ($buf === '') ? $s : $buf . ' ' . $s;
                    $buf_words += $w;
                }
            }
            if ($buf !== '') $chunks[] = $buf;
            $out = '';
            foreach ($chunks as $c) $out .= '<p' . $attrs . '>' . trim($c) . '</p>';
            return $out;
        }, $html);
    }

    /**
 * split blank-line-delimited (classic-editor / plain-text)
 * paragraphs over 150 words at sentence boundaries. `split_long_paragraphs`
 * above only touches `<p>`-wrapped blocks (Gutenberg / AI HTML), but
 * RANKFORGE_Scorer counts paragraphs split on `</p>`, `<br><br>` AND `\n\n`,
 * so a classic-editor post with a giant `\n\n`-delimited paragraph would
 * otherwise never get fixed. Blocks already containing `<p>` are left for
 * the `<p>` pass. A single >150-word run-on sentence (no safe split point)
 * is left intact rather than mangled.
 */
    private static function split_long_paragraphs_textblocks($content, $max = 150, $target = 110) {
        if (!class_exists('RANKFORGE_Scorer')) {
            require_once __DIR__ . '/class-seo-scorer.php';
        }
        $blocks = preg_split('/\n{2,}/', (string) $content);
        if (!$blocks || count($blocks) < 1) return $content;
        $changed = false;
        $out = [];
        foreach ($blocks as $block) {
            if (stripos($block, '<p') !== false) { $out[] = $block; continue; } // handled by <p> pass
            $plain = trim(preg_replace('/\s+/u', ' ', wp_strip_all_tags($block)));
            if (RANKFORGE_Scorer::word_count($plain) <= $max) { $out[] = $block; continue; }
            $sentences = preg_split('/(?<=[.!?…。！？])\s+/u', trim($block), -1, PREG_SPLIT_NO_EMPTY);
            if (count($sentences) < 2) { $out[] = $block; continue; } // one giant sentence — don't mangle
            $buf = ''; $bw = 0; $chunks = [];
            foreach ($sentences as $s) {
                $w = RANKFORGE_Scorer::word_count(wp_strip_all_tags($s));
                if ($buf !== '' && $bw + $w > $target) { $chunks[] = $buf; $buf = $s; $bw = $w; }
                else { $buf = ($buf === '') ? $s : $buf . ' ' . $s; $bw += $w; }
            }
            if ($buf !== '') $chunks[] = $buf;
            if (count($chunks) > 1) { $changed = true; $out[] = implode("\n\n", $chunks); }
            else { $out[] = $block; }
        }
        return $changed ? implode("\n\n", $out) : $content;
    }

    /** Run both paragraph-split passes (<p>-wrapped + blank-line-delimited). */
    private static function split_paragraphs_content($content) {
        $content = (string) $content;
        $out = self::split_long_paragraphs($content);
        $out = self::split_long_paragraphs_textblocks($out);
        return $out;
    }

    /**
 * deterministic "break up long paragraphs" fix — no AI, no credits.
 * Returns the reformatted content (NOT persisted — the drawer previews it
 * first), or a WP_Error when there's nothing to split.
 */
    private function split_paragraphs_suggestion($post_id) {
        $post = get_post($post_id);
        if (!$post) return new WP_Error('no_post', 'Post not found');
        $current = (string) $post->post_content;
        if (trim(wp_strip_all_tags($current)) === '') {
            return new WP_Error('no_content', __('There is no editable content to reformat.', 'rankforge'));
        }
        $split = self::split_paragraphs_content($current);
        if ($split === $current || trim($split) === '') {
            return new WP_Error('no_change', __('No paragraph over 150 words could be split automatically — the long passage is a single run-on sentence. Split it manually at a sentence boundary.', 'rankforge'));
        }
        return $split;
    }

    /**
 * Build a table of contents from the post's H2–H4 headings: add anchor IDs
 * to headings that lack one and prepend a linked <nav>. Deterministic — no
 * AI, no credits. Returns the full content with the ToC (NOT persisted).
 */
    private function insert_toc_suggestion($post_id) {
        $post = get_post($post_id);
        if (!$post) return new WP_Error('no_post', 'Post not found');
        $html = (string) $post->post_content;
        if (!preg_match_all('/<h([2-4])([^>]*)>(.*?)<\/h\1>/is', $html, $m, PREG_SET_ORDER) || count($m) < 2) {
            return new WP_Error('no_headings', __('Add at least two H2/H3 headings first — a table of contents links to them.', 'rankforge'));
        }
        $new_html = $html;
        $items = '';
        $used = [];
        foreach ($m as $h) {
            $level = (int) $h[1];
            $attrs = $h[2];
            $text  = trim(wp_strip_all_tags($h[3]));
            if ($text === '') continue;
            if (preg_match('/id=["\']([^"\']+)["\']/i', $attrs, $idm)) {
                $id = $idm[1];
            } else {
                $id = sanitize_title($text) ?: 'section';
                $base = $id; $i = 2; while (isset($used[$id])) { $id = $base . '-' . $i; $i++; }
                $withId = '<h' . $level . $attrs . ' id="' . esc_attr($id) . '">' . $h[3] . '</h' . $level . '>';
                $pos = strpos($new_html, $h[0]);
                if ($pos !== false) $new_html = substr_replace($new_html, $withId, $pos, strlen($h[0]));
            }
            $used[$id] = true;
            $cls = $level >= 3 ? ' class="sf-toc-sub"' : '';
            $items .= '<li' . $cls . '><a href="#' . esc_attr($id) . '">' . esc_html($text) . '</a></li>';
        }
        if ($items === '') return new WP_Error('no_headings', __('No usable headings found.', 'rankforge'));
        $toc = '<nav class="rankforge-toc" aria-label="' . esc_attr__('Table of contents', 'rankforge') . '">'
             . '<strong>' . esc_html__('Table of contents', 'rankforge') . '</strong><ul>' . $items . '</ul></nav>' . "\n\n";
        return $toc . $new_html;
    }

    /**
 * Add 1–3 external links to authoritative sources. AI, but constrained to
 * real well-known domains and to changing nothing but the added <a> tags;
 * the drawer previews it so the user can verify the URLs before saving.
 */
    private function add_external_links_suggestion($post_id) {
        $post = get_post($post_id);
        if (!$post) return new WP_Error('no_post', 'Post not found');
        $current = (string) $post->post_content;
        if (trim(wp_strip_all_tags($current)) === '') {
            return new WP_Error('no_content', __('There is no content to add links to.', 'rankforge'));
        }
        $resp = RANKFORGE_Forge_API::instance()->ai_add_external_links($current);
        if (is_wp_error($resp)) return $resp;
        $out = is_array($resp) ? ($resp['result'] ?? '') : (string) $resp;
        $out = trim((string) $out);
        $out = preg_replace('/^```html?\s*/i', '', $out);
        $out = preg_replace('/\s*```$/', '', $out);
        return trim($out);
    }

    /**
 * Put the focus keyword into ONE subheading. Rather than rewrite the whole
 * article (which risks dropping links/content — the way a generic rewrite
 * can), we target a single H2–H4 that lacks the keyword, ask the model to
 * rewrite just that heading text, and splice it back so everything else
 * stays byte-for-byte. Guarantees our own "keyword in subheadings" check
 * passes via a deterministic fallback if the model strays. Returns the full
 * content (NOT persisted — the drawer previews it first).
 */
    private function add_keyword_to_subheading_suggestion($post_id) {
        $post = get_post($post_id);
        if (!$post) return new WP_Error('no_post', 'Post not found');
        if (!class_exists('RANKFORGE_Scorer')) require_once __DIR__ . '/class-seo-scorer.php';
        $keyword = trim((string) get_post_meta($post_id, '_rankforge_focus_keyword', true));
        if ($keyword === '') {
            return new WP_Error('no_keyword', __('Set a focus keyword first — then we can work it into a subheading.', 'rankforge'));
        }
        $html = (string) $post->post_content;
        if (!preg_match_all('/<h([2-4])([^>]*)>(.*?)<\/h\1>/is', $html, $m, PREG_SET_ORDER)) {
            return new WP_Error('no_headings', __('Add an H2 or H3 subheading first — then we can add the keyword to it.', 'rankforge'));
        }
        // Pick the first subheading that doesn't already contain the keyword.
        $target = null;
        foreach ($m as $h) {
            $text = trim(wp_strip_all_tags($h[3]));
            if ($text !== '' && !RANKFORGE_Scorer::keyword_present($keyword, $text)) { $target = $h; break; }
        }
        if ($target === null) {
            return new WP_Error('already_present', __('A subheading already contains the focus keyword.', 'rankforge'));
        }
        $orig_text = trim(wp_strip_all_tags($target[3]));

        $resp = RANKFORGE_Forge_API::instance()->ai_keyword_in_subheading($keyword, $orig_text);
        if (is_wp_error($resp)) return $resp;
        $new_text = is_array($resp) ? ($resp['result'] ?? '') : (string) $resp;
        $new_text = trim(wp_strip_all_tags((string) $new_text));
        $new_text = trim($new_text, " \t\n\r\0\x0B\"'«»“”‘’");

        // Guarantee our own rule: the new heading MUST contain the keyword. If
        // the model failed, fall back to a deterministic, readable insertion.
        if ($new_text === '' || !RANKFORGE_Scorer::keyword_present($keyword, $new_text)) {
            $kw_disp = function_exists('mb_convert_case') ? mb_convert_case($keyword, MB_CASE_TITLE, 'UTF-8') : ucwords($keyword);
            $new_text = $orig_text !== '' ? ($kw_disp . ': ' . $orig_text) : $kw_disp;
        }

        // Splice the new heading back, preserving level + attributes; replace
        // only the FIRST occurrence of this exact heading so nothing else moves.
        $new_heading = '<h' . $target[1] . $target[2] . '>' . esc_html($new_text) . '</h' . $target[1] . '>';
        $pos = strpos($html, $target[0]);
        if ($pos === false) return new WP_Error('not_found', __('Could not locate the subheading to update.', 'rankforge'));
        return substr_replace($html, $new_heading, $pos, strlen($target[0]));
    }

    public function suggest_links($req) {
        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        // RANKFORGE_Internal_Links lives in the Pro add-on (post-split).
        // On Free-only sites return a clean "feature not available" signal
        // instead of fataling.
        if (!class_exists('RANKFORGE_Internal_Links')) {
            return new WP_Error(
                'feature_not_available',
                __('Internal Links requires the Rank Forge Pro add-on.', 'rankforge'),
                ['status' => 404]
            );
        }
        // The class_exists guard above is the gate — RANKFORGE_Internal_Links
        // ships only in the Pro add-on, so nothing bundled here is withheld.
        return rest_ensure_response(RANKFORGE_Internal_Links::instance()->suggest($post_id));
    }

    public function get_schema($req) {
        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $type = get_post_meta($post_id, '_rankforge_schema_type', true) ?: RANKFORGE_Schema::instance()->auto_detect_type($post_id);
        return rest_ensure_response(RANKFORGE_Schema::instance()->generate($post_id, $type));
    }

    public function dashboard($req) {
        return rest_ensure_response(RANKFORGE_Dashboard::instance()->get_stats());
    }

    /**
 * bulk fix — apply AI fix to multiple posts that share the same
 * issue type. Body: { post_ids: [1,2,3], issue_message: "..." }.
 * Runs fixes sequentially; returns per-post success/failure so the UI
 * can update the row without re-running the entire audit.
 */
    public function fix_issue_bulk($req) {
        // SF-QA-001: PRO gate (defense-in-depth alongside site_audit check below).
        if ($err = self::require_ai_pro()) return $err;

        // each batch processes up to 25 posts × ~3s AI call each,
        // plus wp_update_post + analyze. Default PHP max_execution_time
        // (30-60s) is tight. Bump to 120s for THIS request so one slow OpenAI
        // response doesn't trip 504 before the loop finishes; client-side
        // chunks to 5 anyway so practical cost stays well under the cap.
        @set_time_limit(120); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- intentional runtime limit
        @ini_set('memory_limit', '256M'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- intentional runtime limit

        $params = $req->get_json_params();
        $post_ids = array_map('intval', (array)($params['post_ids'] ?? []));
        $issue_message = sanitize_text_field($params['issue_message'] ?? '');
        if (!$post_ids) return new WP_Error('no_posts', 'post_ids required', ['status' => 400]);
        if (!$issue_message) return new WP_Error('no_issue', 'issue_message required', ['status' => 400]);

        $ai = RANKFORGE_AI_Client::instance();
        if (!$ai->is_configured()) {
            return rest_ensure_response(['success' => false, 'message' => 'AI not configured']);
        }
        if (!RANKFORGE_License::instance()->can('site_audit')) {
            return rest_ensure_response(['success' => false, 'message' => 'Pro license required']);
        }

        // Cap to prevent runaway AI spend. 25 is enough for a sensible
        // bulk pass; larger groups should be split client-side.
        $post_ids = array_slice(array_unique($post_ids), 0, 25);

        $results = [];
        foreach ($post_ids as $pid) {
            // Per-post BOLA guard — the bulk endpoint accepts arbitrary IDs
            // from the client; without this an author can rewrite any post.
            if (!current_user_can('edit_post', $pid)) {
                $results[] = ['post_id' => $pid, 'ok' => false, 'error' => 'forbidden'];
                continue;
            }
            try {
                $fixed = $ai->fix_issue($pid, $issue_message);
                if (is_wp_error($fixed)) {
                    if ($fixed->get_error_code() === 'credits_exhausted') {
                        // Stop early — no point hammering further.
                        $results[] = ['post_id' => $pid, 'ok' => false, 'error' => 'credits_exhausted'];
                        break;
                    }
                    $results[] = ['post_id' => $pid, 'ok' => false, 'error' => $fixed->get_error_message()];
                    continue;
                }
                wp_update_post(['ID' => $pid, 'post_content' => $fixed]);
                $results[] = ['post_id' => $pid, 'ok' => true];
            } catch (\Throwable $e) {
                $results[] = ['post_id' => $pid, 'ok' => false, 'error' => $e->getMessage()];
            }
        }

        $ok_count = count(array_filter($results, function ($r) { return !empty($r['ok']); }));

        // / v2: do NOT nuke the entire cached audit on a fix —
        // the tester repeatedly lost their whole audit just for fixing one
        // page. Instead surgically remove the fixed posts from each issue
        // group inside the cached audit; if a group becomes empty, drop it.
        // The audit "stickiness" is preserved across navigation while the
        // post-fix state stays accurate.
        if ($ok_count > 0) {
            $fixed_ids = array_values(array_column(
                array_filter($results, function ($r) { return !empty($r['ok']); }),
                'post_id'
            ));
            self::prune_audit_for_fixed_posts($fixed_ids);
            delete_transient('rankforge_site_audit'); // legacy cache, still nuke
            // Recompute per-post analysis so the audit re-run reflects
            // the new content instead of the pre-fix _rankforge_analysis row.
            foreach ($post_ids as $pid) {
                if (class_exists('RANKFORGE_Analyzer')) {
                    RANKFORGE_Analyzer::instance()->analyze($pid, false);
                }
            }
        }

        return rest_ensure_response([
            'success'  => $ok_count > 0,
            'fixed'    => $ok_count,
            'total'    => count($post_ids),
            'results'  => $results,
            // Fixed post IDs so the UI can remove those rows in place
            // without a full page reload (reload against a freshly-invalidated
            // cache would render an empty "Run Audit" screen).
            'fixed_ids' => array_values(array_column(
                array_filter($results, function ($r) { return !empty($r['ok']); }),
                'post_id'
            )),
        ]);
    }

    public function fix_issue($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $params = $req->get_json_params();
        $issue_message = sanitize_text_field($params['issue_message'] ?? '');
        if (!$issue_message) return new WP_Error('no_issue', 'Issue message required', ['status' => 400]);

        // "break up long paragraphs" is deterministic — no AI, no
        // credit. Handled here too (not just the drawer preview) so the
        // metabox Fix-all bulk path (sfSmartFixOne → /fix/:id) actually splits
        // the paragraph instead of an AI reword that left the >150-word block
        // intact (the "Fixed! but the issue stays" bug the tester reported).
        if (RANKFORGE_Analyzer::is_issue_paragraph_split($issue_message)) {
            $split = $this->split_paragraphs_suggestion($post_id);
            if (is_wp_error($split)) {
                return rest_ensure_response(['success' => false, 'message' => $split->get_error_message()]);
            }
            $original = get_post_field('post_content', $post_id);
            if ($split === $original) {
                return rest_ensure_response(['success' => false, 'message' => __('No paragraph over 150 words could be split automatically.', 'rankforge')]);
            }
            $updated = wp_update_post(['ID' => $post_id, 'post_content' => wp_slash($split)], true);
            if (is_wp_error($updated) || !$updated) {
                return rest_ensure_response(['success' => false, 'message' => is_wp_error($updated) ? $updated->get_error_message() : __('Could not save the fixed content.', 'rankforge')]);
            }
            $new_analysis = RANKFORGE_Analyzer::instance()->analyze($post_id);
            self::prune_audit_for_fixed_posts([$post_id]);
            delete_transient('rankforge_site_audit');
            return rest_ensure_response([
                'success'   => true,
                'message'   => 'Long paragraphs split.',
                'new_score' => $new_analysis['score'] ?? 0,
            ]);
        }

        $ai = RANKFORGE_AI_Client::instance();
        if (!$ai->is_configured()) return rest_ensure_response(['success' => false, 'message' => 'AI not configured']);

        $fixed_content = $ai->fix_issue($post_id, $issue_message);
        if (is_wp_error($fixed_content)) {
            if ($fixed_content->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($fixed_content));
            }
            return rest_ensure_response(['success' => false, 'message' => $fixed_content->get_error_message()]);
        }

        // If the AI returned the same content (common with subjective issues
        // like low readability), skip the write and tell the user explicitly —
        // otherwise the issue will "disappear" after Fix but reappear on
        // reload, because auto_analyze runs against unchanged content.
        $original = get_post_field('post_content', $post_id);
        if (trim($fixed_content) === '' || $original === $fixed_content) {
            return rest_ensure_response([
                'success' => false,
                'message' => __('AI could not improve this issue further. Try editing the content manually or use Content Optimizer.', 'rankforge'),
            ]);
        }

        // wp_update_post expects slashed content — pass unslashed and it will
        // double-slash or lose backslashes in code blocks.
        $updated = wp_update_post([
            'ID'           => $post_id,
            'post_content' => wp_slash($fixed_content),
        ], true);
        if (is_wp_error($updated) || !$updated) {
            return rest_ensure_response([
                'success' => false,
                'message' => is_wp_error($updated) ? $updated->get_error_message() : __('Could not save the fixed content.', 'rankforge'),
            ]);
        }

        // Re-analyze after fix
        $new_analysis = RANKFORGE_Analyzer::instance()->analyze($post_id);

        // / v2: don't nuke the whole cached audit — the
        // tester legitimately complained about losing their audit for
        // fixing one page. Surgically remove this post from issue groups.
        self::prune_audit_for_fixed_posts([$post_id]);
        delete_transient('rankforge_site_audit'); // legacy cache

        return rest_ensure_response([
            'success'   => true,
            'message'   => 'Issue fixed and content updated.',
            'new_score' => $new_analysis['score'] ?? 0,
        ]);
    }

    /**
 * v3: drawer-aware preview for content-rewrite fixes
 * (thin_content, duplicate_titles, no_internal_links, missing_h1,
 * missing_alt). Same AI call as fix_issue but does NOT persist —
 * returns the current post_content + AI rewrite so the drawer can
 * render a side-by-side review UI.
 *
 * Body: { post_id, issue_message, issue_type? }
 */
    public function preview_fix($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $params  = $req->get_json_params();
        $post_id = (int) ($params['post_id'] ?? 0);
        $issue_message = sanitize_text_field((string) ($params['issue_message'] ?? ''));
        $issue_type    = sanitize_key((string) ($params['issue_type'] ?? ''));
        if (!$post_id || !$issue_message) {
            return new WP_Error('bad_request', 'post_id and issue_message required', ['status' => 400]);
        }
        if ($err = self::require_can_edit_post($post_id)) return $err;

        $current = (string) get_post_field('post_content', $post_id);

        // Table of contents is built deterministically from the headings — no
        // AI, no credits — so it runs before the AI-configured gate.
        if ($issue_type === 'insert_toc') {
            $suggestion = $this->insert_toc_suggestion($post_id);
        } elseif ($issue_type === 'short_paragraphs' || RANKFORGE_Analyzer::is_issue_paragraph_split($issue_message)) {
            // break up long paragraphs deterministically (sentence-
            // boundary split) — no AI, no credits, and it always resolves the
            // issue. The drawer still previews it Before/After.
            $suggestion = $this->split_paragraphs_suggestion($post_id);
        } else {
            $ai = RANKFORGE_AI_Client::instance();
            if (!$ai->is_configured()) {
                return rest_ensure_response(['success' => false, 'error' => 'AI not configured']);
            }
            if ($issue_type === 'expand_content') {
                $suggestion = $this->expand_content_suggestion($post_id);       // ≥700 words, preserve everything
            } elseif ($issue_type === 'add_external_links') {
                $suggestion = $this->add_external_links_suggestion($post_id);   // links to authoritative sources
            } elseif ($issue_type === 'keyword_subheading') {
                $suggestion = $this->add_keyword_to_subheading_suggestion($post_id); // keyword into ONE subheading
            } else {
                $suggestion = $ai->fix_issue($post_id, $issue_message);         // generic content rewrite
                // The manual "Fix" goes to the Worker, whose prompt we don't
                // control here — so hold its output to our own readability rules
                // too: if it's still hard to read, one corrective simplify pass
                // (English only, kept only when Flesch actually improves).
                if (!is_wp_error($suggestion) && is_string($suggestion) && trim($suggestion) !== '') {
                    $suggestion = $this->enforce_readability($suggestion, $post_id);
                }
            }
        }
        if (is_wp_error($suggestion)) {
            if ($suggestion->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($suggestion));
            }
            return rest_ensure_response(['success' => false, 'error' => $suggestion->get_error_message()]);
        }
        if (trim((string) $suggestion) === '' || $current === $suggestion) {
            return rest_ensure_response([
                'success' => false,
                'error'   => __('AI could not improve this issue further. Edit the content manually or try a different fix.', 'rankforge'),
            ]);
        }

        return rest_ensure_response([
            'success'    => true,
            'post_id'    => $post_id,
            'type'       => 'content',
            'issue_type' => $issue_type,
            'current'    => $current,
            'suggestion' => (string) $suggestion,
        ]);
    }

    /**
 * v3: persist a user-reviewed (possibly edited) content fix.
 * Body: { post_id, value, issue_type? }
 * issue_type drives the audit-cache prune scope so we only drop the
 * matching issue group from the persisted audit.
 */
    public function apply_fix($req) {
        // SF-QA-001: PRO gate — apply_fix persists a value that was generated
        // by preview_fix (which already spent credits). Gating here too so a
        // Free user cannot replay a previously captured preview response.
        if ($err = self::require_ai_pro()) return $err;

        $params = $req->get_json_params();
        $post_id = (int) ($params['post_id'] ?? 0);
        $value   = isset($params['value']) ? (string) $params['value'] : '';
        $issue_type = sanitize_key((string) ($params['issue_type'] ?? ''));
        if (!$post_id || trim($value) === '') {
            return new WP_Error('bad_request', 'post_id and value required', ['status' => 400]);
        }
        if ($err = self::require_can_edit_post($post_id)) return $err;

        $updated = wp_update_post([
            'ID'           => $post_id,
            'post_content' => wp_slash($value),
        ], true);
        if (is_wp_error($updated) || !$updated) {
            return rest_ensure_response([
                'success' => false,
                'message' => is_wp_error($updated) ? $updated->get_error_message() : __('Could not save the fixed content.', 'rankforge'),
            ]);
        }

        // Re-analyze so the metabox / dashboard score reflects the change.
        $new_analysis = class_exists('RANKFORGE_Analyzer') ? RANKFORGE_Analyzer::instance()->analyze($post_id) : null;

        // Surgical audit cache prune — only drop the matching issue group.
        $only_codes = $issue_type ? [$issue_type] : [];
        self::prune_audit_for_fixed_posts([$post_id], $only_codes);
        delete_transient('rankforge_site_audit'); // legacy

        return rest_ensure_response([
            'success'   => true,
            'post_id'   => $post_id,
            'new_score' => is_array($new_analysis) ? ($new_analysis['score'] ?? 0) : 0,
        ]);
    }

    public function auto_insert_links($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $post    = get_post($post_id);
        if (!$post) return rest_ensure_response(['success' => false, 'message' => 'Post not found']);

        // previously this endpoint sent the whole post content to the AI
        // and replaced it with the model's "rewritten with links" reply. The
        // model often trimmed or reworded the body, which tanked the on-page
        // SEO score (headings, keyword density, word count all changed).

        // New behaviour: run the local auto-linker, which only wraps existing
        // anchor phrases in <a> tags and leaves the rest of the content byte
        // for byte intact — so title, description and the rest of the SEO
        // analysis can't regress as a side effect of adding interlinks.
        $auto = RANKFORGE_Auto_Links::instance();
        $related = $auto->find_related_posts($post_id, 10);
        if (empty($related)) {
            return rest_ensure_response([
                'success' => false,
                'message' => 'No related posts found to link to.',
            ]);
        }

        $before_content = $post->post_content;
        $max_links      = max(1, (int) $auto->get_max_links());

        $auto->insert_links_in_content($post_id, $related, $max_links);

        $after_content = get_post_field('post_content', $post_id);
        $links_added   = 0;
        if ($after_content !== $before_content) {
            // Count newly-inserted autolink markers.
            $links_added = max(
                0,
                substr_count($after_content, '<!-- rankforge-autolink -->')
                - substr_count($before_content, '<!-- rankforge-autolink -->')
            );
        }

        $new_analysis = RANKFORGE_Analyzer::instance()->analyze($post_id);

        return rest_ensure_response([
            'success'     => true,
            'message'     => $links_added > 0
                /* translators: %d: number of internal links inserted */
                ? sprintf(_n('%d internal link inserted.', '%d internal links inserted.', $links_added, 'rankforge'), $links_added)
                : 'No suitable anchor text found — content unchanged.',
            'links_added' => $links_added,
            'new_score'   => $new_analysis['score'] ?? 0,
        ]);
    }

    public function ai_readiness($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $ai = RANKFORGE_AI_Client::instance();
        if (!$ai->is_configured()) return rest_ensure_response(['score' => 0, 'checks' => [], 'suggestions' => []]);
        $result = $ai->analyze_ai_readiness($post_id);
        return rest_ensure_response($result ?: ['score' => 0, 'checks' => [], 'suggestions' => []]);
    }

    public function generate_local_seo($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $ai = RANKFORGE_AI_Client::instance();
        if (!$ai->is_configured()) return rest_ensure_response(['error' => 'AI not configured']);

        $params = $req->get_json_params();
        $biz_name = sanitize_text_field($params['business_name'] ?? get_bloginfo('name'));
        $biz_city = sanitize_text_field($params['city'] ?? '');
        $biz_desc = sanitize_text_field($params['business_desc'] ?? '');
        $site_url = home_url('/');

        $system = 'You are a Local SEO and Schema.org expert. Based on the business information, generate structured business data.

IMPORTANT:
- If the business has a physical location customers visit (store, restaurant, clinic, salon) → use a specific LocalBusiness subtype
- If the business is online-only, freelancer, agency, or works remotely → use "Organization" or "ProfessionalService"
- Choose the most specific Schema.org type that fits

Return a JSON object:
{
  "business_name": "...",
  "business_type": "Organization, ProfessionalService, Restaurant, Dentist, etc.",
  "phone": "local format",
  "email": "contact@domain.com",
  "street": "if applicable, otherwise empty",
  "city": "...",
  "state": "...",
  "zip": "...",
  "country": "2-letter code",
  "price_range": "$, $$, $$$, or empty",
  "opening_hours": "Mo-Fr 09:00-17:00 format, one per line, or empty for online-only",
  "social_urls": "one URL per line"
}

Leave fields empty string "" if not applicable (e.g. no street for online business).
Return ONLY the JSON object.';

        $user = "Business name: {$biz_name}\nCity: {$biz_city}\nWhat they do: {$biz_desc}\nWebsite: {$site_url}";

        // Dedicated generate_local_business action (real role:system).
        $resp = RANKFORGE_Forge_API::instance()->ai_generate_local_business($biz_name, $biz_city, $biz_desc, $site_url);
        if (is_wp_error($resp)) {
            if ($resp->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($resp));
            }
            return rest_ensure_response(['error' => $resp->get_error_message()]);
        }
        // The json action returns a parsed object; normalize to a JSON string
        // for the existing decode path below.
        $result = is_array($resp['result'] ?? null) ? wp_json_encode($resp['result']) : (string) ($resp['result'] ?? '');

        $data = json_decode($result, true);
        if (!is_array($data)) {
            if (preg_match('/\{[\s\S]*\}/', $result, $m)) {
                $data = json_decode($m[0], true);
            }
        }

        return rest_ensure_response(is_array($data) ? $data : ['error' => 'Could not parse AI response']);
    }

    public function generate_brief($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $params = $req->get_json_params();
        $keyword = sanitize_text_field($params['keyword'] ?? '');
        if (!$keyword) return new WP_Error('no_keyword', 'Keyword required', ['status' => 400]);

        $ai = RANKFORGE_AI_Client::instance();
        if (!$ai->is_configured()) return rest_ensure_response(['error' => 'AI not configured']);

        $brief = $ai->generate_content_brief($keyword);
        if (is_wp_error($brief)) {
            if ($brief->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($brief));
            }
            return rest_ensure_response(['error' => $brief->get_error_message()]);
        }

        // persist the generated brief into user meta so it can be
        // revisited later without spending credits. The ID is returned so
        // the client can highlight the just-created entry in the list.
        $saved = self::save_brief_to_history($keyword, $brief);
        if ($saved) {
            $brief['history_id']      = $saved['id'];
            $brief['history_created'] = $saved['created_at'];
        }

        return rest_ensure_response($brief);
    }

    // ─── drawer preview + apply for AI meta fixes ────────────────────

    /**
 * POST /rankforge/v1/meta/preview
 * Body: { post_id, type: 'title' | 'description' | 'keyword' }
 * Spends credits to generate an AI value but does NOT persist it. Returns
 * both the current stored value (for the drawer's "Current Value" pane)
 * and the fresh AI suggestion (for the editable pane).
 */
    public function preview_meta($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $params  = $req->get_json_params();
        $post_id = (int) ($params['post_id'] ?? 0);
        $type    = in_array(($params['type'] ?? ''), ['title', 'description', 'keyword'], true) ? $params['type'] : '';
        if (!$post_id || !$type) {
            return new WP_Error('bad_request', 'post_id and type (title|description|keyword) are required', ['status' => 400]);
        }
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $post = get_post($post_id);

        $current = '';
        $suggestion = '';
        if ($type === 'title') {
            $current = (string) get_post_meta($post_id, '_rankforge_meta_title', true);
            $result = RANKFORGE_Meta_Generator::instance()->generate_title($post_id);
            if (is_wp_error($result)) {
                if ($result->get_error_code() === 'credits_exhausted') return rest_ensure_response(self::credits_exhausted_response($result));
                return rest_ensure_response(['success' => false, 'error' => $result->get_error_message()]);
            }
            // generate_title returns a string and does NOT persist — preview stays clean.
            $suggestion = (string) $result;
        } elseif ($type === 'description') {
            $current = (string) get_post_meta($post_id, '_rankforge_meta_description', true);
            $result = RANKFORGE_Meta_Generator::instance()->generate_description($post_id);
            if (is_wp_error($result)) {
                if ($result->get_error_code() === 'credits_exhausted') return rest_ensure_response(self::credits_exhausted_response($result));
                return rest_ensure_response(['success' => false, 'error' => $result->get_error_message()]);
            }
            $suggestion = (string) $result;
        } elseif ($type === 'keyword') {
            $current = (string) get_post_meta($post_id, '_rankforge_focus_keyword', true);
            $content = wp_strip_all_tags(apply_filters('the_content', $post->post_content)); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- intentional ACF/WPML/core compatibility
            $content = mb_substr($content, 0, 4000);
            $resp = RANKFORGE_Forge_API::instance()->ai_generate_keyword($content);
            if (is_wp_error($resp)) {
                if ($resp->get_error_code() === 'credits_exhausted') return rest_ensure_response(self::credits_exhausted_response($resp));
                return rest_ensure_response(['success' => false, 'error' => $resp->get_error_message()]);
            }
            $kw = is_array($resp) ? ($resp['result'] ?? '') : (string) $resp;
            $suggestion = trim(wp_strip_all_tags((string) $kw), " \t\n\r\0\x0B\"'«»“”‘’.?!,:;()[]{}");
        }

        return rest_ensure_response([
            'success'    => true,
            'post_id'    => $post_id,
            'type'       => $type,
            'current'    => $current,
            'suggestion' => $suggestion,
        ]);
    }

    /**
 * POST /rankforge/v1/meta/apply
 * Body: { post_id, type, value }
 * Persists the user's reviewed value (possibly edited). No credits consumed.
 */
    public function apply_meta($req) {
        // SF-QA-001: PRO gate — apply_meta persists a value generated by
        // preview_meta. Gate it here too to block replayed preview responses.
        if ($err = self::require_ai_pro()) return $err;

        $params  = $req->get_json_params();
        $post_id = (int) ($params['post_id'] ?? 0);
        $type    = in_array(($params['type'] ?? ''), ['title', 'description', 'keyword'], true) ? $params['type'] : '';
        $value   = isset($params['value']) ? (string) $params['value'] : '';
        if (!$post_id || !$type) {
            return new WP_Error('bad_request', 'post_id and type required', ['status' => 400]);
        }
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $key = [
            'title'       => '_rankforge_meta_title',
            'description' => '_rankforge_meta_description',
            'keyword'     => '_rankforge_focus_keyword',
        ][$type];
        $sanitized = $type === 'description' ? sanitize_textarea_field($value) : sanitize_text_field($value);
        update_post_meta($post_id, $key, $sanitized);
        // Recompute score without AI so the metabox picks up the new value.
        if (class_exists('RANKFORGE_Analyzer')) {
            RANKFORGE_Analyzer::instance()->analyze($post_id, false);
        }
        // v2: drawer Apply needs to mutate the cached audit too —
        // the issue is now resolved, so the post should disappear from
        // the matching issue group (or the whole group if it was the
        // last one). Type-aware pruning so we only touch the issue codes
        // that match this meta change.
        $type_codes = [
            'title'       => ['missing_meta_title', 'long_title', 'short_title'],
            'description' => ['missing_meta_desc', 'long_description', 'short_description'],
            'keyword'     => ['missing_keyword'],
        ];
        self::prune_audit_for_fixed_posts([$post_id], $type_codes[$type] ?? []);
        return rest_ensure_response(['success' => true, 'post_id' => $post_id, 'type' => $type, 'value' => $sanitized]);
    }

    // ─── v2: surgical audit cache mutation ────────────────────────────
    // Replaces the previous "nuke the whole audit on any fix" behaviour.
    // Removes the fixed posts from each issue group inside the cached audit
    // option so navigation away+back keeps the user's audit context intact.

    // $only_codes — optional list of issue type codes to limit the prune to
    // (e.g. only 'missing_meta_title' for an apply_meta(title) call). Empty
    // means prune across every issue group (used by fix_issue / fix_issue_bulk
    // where we don't know which issue type the AI handled).
    private static function prune_audit_for_fixed_posts(array $post_ids, array $only_codes = []): void {
        if (empty($post_ids)) return;
        $audit = get_option('rankforge_site_audit_result');
        if (!is_array($audit) || empty($audit['issues']) || !is_array($audit['issues'])) return;

        $fixed = array_flip(array_map('intval', $post_ids));
        $changed = false;
        $surviving_issues = [];
        $counts = ['critical' => 0, 'warning' => 0, 'info' => 0];

        foreach ($audit['issues'] as $issue) {
            $code = (string) ($issue['type'] ?? '');
            // Issue is in scope of this prune?
            $in_scope = empty($only_codes) || in_array($code, $only_codes, true);
            if (!$in_scope) {
                $surviving_issues[] = $issue;
                $counts[$issue['severity']] = ($counts[$issue['severity']] ?? 0) + 1;
                continue;
            }
            $posts = isset($issue['posts']) && is_array($issue['posts']) ? $issue['posts'] : [];
            if (empty($posts)) {
                // Singleton issue — keep as-is unless it's directly about a fixed post.
                if (!empty($issue['post']['id']) && isset($fixed[(int) $issue['post']['id']])) {
                    $changed = true;
                    continue; // drop
                }
                $surviving_issues[] = $issue;
                $counts[$issue['severity']] = ($counts[$issue['severity']] ?? 0) + 1;
                continue;
            }
            $remaining = [];
            foreach ($posts as $p) {
                $pid = isset($p['id']) ? (int) $p['id'] : 0;
                if ($pid && isset($fixed[$pid])) { $changed = true; continue; }
                $remaining[] = $p;
            }
            if (empty($remaining)) {
                // All posts in this issue group were fixed — drop the group.
                continue;
            }
            $issue['posts'] = $remaining;
            $surviving_issues[] = $issue;
            $counts[$issue['severity']] = ($counts[$issue['severity']] ?? 0) + 1;
        }

        if (!$changed) return;

        $audit['issues'] = $surviving_issues;
        $audit['counts'] = $counts;
        update_option('rankforge_site_audit_result', $audit, false);
    }

    // ─── brief history storage (user meta, capped at 50) ─────────────

    const BRIEF_HISTORY_META = '_rankforge_brief_history';
    const BRIEF_HISTORY_MAX  = 50;

    private static function read_brief_history($user_id = 0) {
        $user_id = $user_id ?: get_current_user_id();
        $raw = get_user_meta($user_id, self::BRIEF_HISTORY_META, true);
        if (!is_array($raw)) return [];
        return $raw;
    }

    private static function write_brief_history(array $history, $user_id = 0) {
        $user_id = $user_id ?: get_current_user_id();
        if (count($history) > self::BRIEF_HISTORY_MAX) {
            $history = array_slice($history, 0, self::BRIEF_HISTORY_MAX);
        }
        update_user_meta($user_id, self::BRIEF_HISTORY_META, $history);
    }

    private static function save_brief_to_history($keyword, $brief) {
        if (!is_array($brief)) return null;
        // Strip the history envelope fields if the worker ever starts returning them.
        unset($brief['history_id'], $brief['history_created']);

        $entry = [
            'id'         => substr(wp_generate_uuid4(), 0, 12),
            'keyword'    => (string) $keyword,
            'created_at' => time(),
            'result'     => $brief,
        ];
        $history = self::read_brief_history();
        array_unshift($history, $entry);
        self::write_brief_history($history);
        return $entry;
    }

    /**
 * GET /rankforge/v1/brief/history
 * Returns lightweight summaries (no full result payload) so the history
 * panel stays snappy even with 50 entries.
 */
    public function list_briefs($req) {
        $history = self::read_brief_history();
        $summaries = array_map(function($entry){
            return [
                'id'         => (string) ($entry['id'] ?? ''),
                'keyword'    => (string) ($entry['keyword'] ?? ''),
                'created_at' => (int) ($entry['created_at'] ?? 0),
            ];
        }, $history);
        return rest_ensure_response([
            'briefs' => $summaries,
            'max'    => self::BRIEF_HISTORY_MAX,
        ]);
    }

    /**
 * GET /rankforge/v1/brief/history/:id
 * Full brief payload. Free to open — the model call was already paid for.
 */
    public function get_brief($req) {
        $id = (string) ($req['id'] ?? '');
        $history = self::read_brief_history();
        foreach ($history as $entry) {
            if (($entry['id'] ?? '') === $id) {
                return rest_ensure_response([
                    'id'         => $entry['id'],
                    'keyword'    => $entry['keyword'] ?? '',
                    'created_at' => $entry['created_at'] ?? 0,
                    'result'     => $entry['result'] ?? [],
                ]);
            }
        }
        return new WP_Error('not_found', 'Brief not found', ['status' => 404]);
    }

    /**
 * DELETE /rankforge/v1/brief/history/:id
 */
    public function delete_brief($req) {
        $id = (string) ($req['id'] ?? '');
        $history = self::read_brief_history();
        $next = array_values(array_filter($history, function($entry) use ($id) {
            return ($entry['id'] ?? '') !== $id;
        }));
        if (count($next) === count($history)) {
            return new WP_Error('not_found', 'Brief not found', ['status' => 404]);
        }
        self::write_brief_history($next);
        return rest_ensure_response(['success' => true]);
    }

    public function check_broken_links($req) {
        if ($err = self::require_internal_links_pro()) return $err;
        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        if (!class_exists('RANKFORGE_AI_Client')) {
            return new WP_Error('pro_required', __('Broken-link scanning requires Rank Forge Pro.', 'rankforge'), ['status' => 403]);
        }
        $ai = RANKFORGE_AI_Client::instance();
        $links = $ai->check_broken_links($post_id);
        return rest_ensure_response($links);
    }

    public function detect_schema($req) {
        // SF-QA-001: PRO gate — AI schema detection uses credits.
        if ($err = self::require_ai_pro()) return $err;

        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $post = get_post($post_id);
        if (!$post) return new WP_Error('not_found', 'Post not found', ['status' => 404]);

        // Force re-detection (this is an explicit user action from the Schema Builder UI)
        $result = RANKFORGE_Schema::instance()->ai_detect_and_generate($post_id, true);

        if (!$result) {
            return rest_ensure_response([
                'success'        => false,
                'message'        => 'Could not detect schema for this post.',
                'detected_types' => [],
                'json_ld'        => [],
                'source'         => 'none',
            ]);
        }

        return rest_ensure_response([
            'success'        => true,
            'detected_types' => $result['detected_types'],
            'json_ld'        => $result['json_ld'],
            'source'         => $result['source'] ?? 'ai',
            'generated_at'   => $result['generated_at'] ?? '',
        ]);
    }

    public function optimize_content($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $params = $req->get_json_params();
        $force = !empty($params['force']);

        $result = RANKFORGE_Content_Optimizer::instance()->optimize($post_id, $force);
        if (is_wp_error($result)) {
            if ($result->get_error_code() === 'credits_exhausted') {
                return rest_ensure_response(self::credits_exhausted_response($result));
            }
            return rest_ensure_response([
                'success' => false,
                'message' => $result->get_error_message(),
            ]);
        }

        return rest_ensure_response(array_merge(['success' => true], $result));
    }

    public function apply_optimization($req) {
        // SF-QA-001: PRO gate.
        if ($err = self::require_ai_pro()) return $err;

        $post_id = (int) $req['post_id'];
        if ($err = self::require_can_edit_post($post_id)) return $err;
        $params = $req->get_json_params();
        $type = sanitize_text_field($params['type'] ?? '');
        $content = wp_kses_post($params['content'] ?? '');
        $position = sanitize_text_field($params['position'] ?? 'before_end');

        if (empty($content)) {
            return new WP_Error('no_content', 'Content to insert is required', ['status' => 400]);
        }

        $result = RANKFORGE_Content_Optimizer::instance()->apply_suggestion($post_id, $type, $content, $position);
        if (is_wp_error($result)) {
            return rest_ensure_response([
                'success' => false,
                'message' => $result->get_error_message(),
            ]);
        }

        return rest_ensure_response($result);
    }
}
