<?php
/**
 * Rank Booster Main Class
 *
 * Handles initialisation, editor detection, URL resolution,
 * and all Rank Booster API communication.
 */

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

class Rank_Booster {

    const API_BASE = 'https://api.morningscore.io/v1/page-booster';

    /**
     * Initialize Rank Booster — called from rank-main.php on plugins_loaded.
     */
    public static function init() {
        // ── Frontend: load only what's needed ──
        // Include wp_doing_ajax() so Action Scheduler async runner (admin-ajax.php)
        // registers rank_booster_process_page / rank_booster_poll_job hooks.
        if ( ! is_admin() && ! wp_doing_cron() && ! wp_doing_ajax() ) {
            // Preview URLs (e.g. /some-post/?rank_booster_preview=nonce)
            if ( isset( $_GET['rank_booster_preview'] ) ) {
                require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-preview.php';
                Rank_Booster_Preview::init();
            }
            // Divi Visual Builder (?et_fb=1): inject AI draft into post_content.
            if ( isset( $_GET['et_fb'] ) ) {
                require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-divi.php';
                Rank_Booster_Divi::init();
            }
            // WPBakery frontend editor: inject AI draft into post_content.
            if ( isset( $_GET['vc_editable'] ) || isset( $_GET['vc_action'] ) ) {
                require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-wpbakery.php';
                Rank_Booster_WPBakery::init();
            }
            // Breakdance builder: inject AI draft into _breakdance_data.
            if ( isset( $_GET['breakdance'] ) || isset( $_GET['breakdance_iframe'] ) ) {
                require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-breakdance.php';
                Rank_Booster_Breakdance::init();
            }
            return; // Nothing else needed on regular frontend page loads.
        }

        // ── Admin / AJAX / WP-Cron context: full init ──
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-db.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-ajax.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-processor.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-preview.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-elementor.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-divi.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-wpbakery.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-breakdance.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-render-probe.php';
        require_once RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-customfields.php';

        // Auto-create/upgrade table if needed (avoids needing plugin reactivation)
        if ( is_admin() && ! wp_doing_ajax() ) {
            Rank_Booster_DB::maybe_upgrade();
        }

        Rank_Booster_Ajax::init();
        Rank_Booster_Processor::init();
        Rank_Booster_Elementor::init();
        Rank_Booster_Divi::init();
        Rank_Booster_WPBakery::init();
        Rank_Booster_Breakdance::init();

        // Inject AI draft into the WP editor when opened with ?rank_booster_inject=JOB_ID
        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_editor_inject' ) );

        // Auto-mark job as pushed_live when user saves from Classic/Gutenberg after Edit Draft.
        add_action( 'save_post', array( __CLASS__, 'on_classic_gutenberg_save' ), 20, 1 );
    }

    /* ───────────────── Editor injection (editPost) ───────────────── */

    /**
     * When the post editor loads with ?rank_booster_inject=JOB_ID,
     * output an inline script that injects AI draft data into the editor
     * via wp.data.dispatch('core/editor').editPost() (Gutenberg) or
     * TinyMCE / jQuery (Classic Editor). User reviews and clicks Update.
     */
    public static function enqueue_editor_inject() {
        global $pagenow;

        // Only on post editor screens
        if ( ! in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
            return;
        }

        $job_id = isset( $_GET['rank_booster_inject'] ) ? absint( $_GET['rank_booster_inject'] ) : 0;
        if ( ! $job_id ) {
            return;
        }

        $job = Rank_Booster_DB::get_job( $job_id );
        if ( ! $job || empty( $job->draft_content ) ) {
            return;
        }

        // Build data for JS
        $inject_data = array(
            'title'   => $job->draft_title ?: '',
            'content' => $job->draft_content,
            'meta'    => array(),
        );

        // SEO title tag — support Yoast, RankMath, SEOPress
        if ( ! empty( $job->draft_title_tag ) ) {
            if ( defined( 'WPSEO_VERSION' ) ) {
                $inject_data['meta']['_yoast_wpseo_title'] = $job->draft_title_tag;
            }
            if ( class_exists( 'RankMath' ) ) {
                $inject_data['meta']['rank_math_title'] = $job->draft_title_tag;
            }
            if ( defined( 'SEOPRESS_VERSION' ) ) {
                $inject_data['meta']['_seopress_titles_title'] = $job->draft_title_tag;
            }
        }

        // Meta description — support Yoast, RankMath, SEOPress
        if ( ! empty( $job->draft_meta ) ) {
            if ( defined( 'WPSEO_VERSION' ) ) {
                $inject_data['meta']['_yoast_wpseo_metadesc'] = $job->draft_meta;
            }
            if ( class_exists( 'RankMath' ) ) {
                $inject_data['meta']['rank_math_description'] = $job->draft_meta;
            }
            if ( defined( 'SEOPRESS_VERSION' ) ) {
                $inject_data['meta']['_seopress_titles_desc'] = $job->draft_meta;
            }
        }

        $editor_type = $job->editor_type ?: 'classic';

        // Inline script — runs after editor is ready
        $script = 'window.rankBoosterInject = ' . wp_json_encode( $inject_data ) . ';'
                . 'window.rankBoosterEditorType = ' . wp_json_encode( $editor_type ) . ';';

        wp_register_script( 'rank-booster-inject-data', false );
        wp_enqueue_script( 'rank-booster-inject-data' );
        wp_add_inline_script( 'rank-booster-inject-data', $script );

        // Enqueue the actual injection logic (depends on inline data script)
        wp_enqueue_script(
            'rank-booster-inject',
            RANK_PLUGIN_URL . 'admin/js/rank-booster-inject.js',
            array( 'jquery', 'rank-booster-inject-data' ),
            filemtime( RANK_PLUGIN_DIR . 'admin/js/rank-booster-inject.js' ),
            true
        );
    }

    /* ───────────────── Classic/Gutenberg save detection ───────────────── */

    /**
     * When a user saves a post after opening the AI draft via "Edit Draft",
     * auto-mark the corresponding rank booster job as pushed_live.
     *
     * Same pattern as Divi (on_divi_save) and Elementor (on_elementor_save).
     *
     * @param int $post_id
     */
    public static function on_classic_gutenberg_save( $post_id ) {
        if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
            return;
        }

        $pending = get_post_meta( $post_id, '_rank_booster_classic_draft_pending', true );
        if ( empty( $pending ) ) {
            return;
        }

        // Clean up the flag.
        delete_post_meta( $post_id, '_rank_booster_classic_draft_pending' );

        // Find the latest completed job for this post and mark it pushed_live.
        global $wpdb;
        $table = Rank_Booster_DB::table_name();
        $job   = $wpdb->get_row( $wpdb->prepare(
            "SELECT id FROM $table WHERE post_id = %d AND status = 'completed' ORDER BY updated_at DESC LIMIT 1",
            $post_id
        ) );

        if ( $job ) {
            Rank_Booster_DB::update_job( $job->id, array(
                'status'         => 'pushed_live',
                'pushed_live_at' => current_time( 'mysql' ),
            ) );
            Rank_Booster_DB::add_log_entry( $job->id, '🚀 Published from editor (Classic/Gutenberg).' );
        }
    }

    /* ───────────────── URL → Post resolution ───────────────── */

    /**
     * Resolve a URL to a WP post ID.
     * Uses the batch cache if available, falls back to url_to_postid().
     *
     * @param string $url Full or relative URL.
     * @return int 0 if not found.
     */
    public static function resolve_url_to_post( $url ) {
        $url = trim( $url );
        if ( empty( $url ) ) {
            return 0;
        }

        // Make absolute if relative
        if ( strpos( $url, 'http' ) !== 0 ) {
            $url = home_url( '/' . ltrim( $url, '/' ) );
        }

        // Check batch cache first (populated by batch_resolve_urls)
        if ( isset( self::$url_post_cache[ $url ] ) ) {
            return self::$url_post_cache[ $url ];
        }

        return self::lookup_post_id_by_url( $url );
    }

    /**
     * Resolve a single absolute URL to a post ID, with a fallback for custom
     * post types that url_to_postid() can't handle.
     *
     * url_to_postid() fails for CPTs whose permalink base has been removed and
     * which therefore live at the site root (e.g. WooCommerce products with the
     * /product/ base stripped). In that case WordPress matches the generic page
     * rewrite rule and looks the slug up as a 'page', returning 0 for the product.
     *
     * The fallback uses get_page_by_path() across all public post types, which
     * matches the slug regardless of permalink base.
     *
     * @param string $full_url Absolute URL.
     * @return int 0 if not found.
     */
    private static function lookup_post_id_by_url( $full_url ) {
        // Primary: url_to_postid (handles posts, pages, and CPTs with a base).
        $post_id = url_to_postid( trailingslashit( $full_url ) );
        if ( ! $post_id ) {
            $post_id = url_to_postid( rtrim( $full_url, '/' ) );
        }
        if ( $post_id ) {
            return (int) $post_id;
        }

        // Fallback: match the URL path against all public post types by slug.
        // Covers root-level CPT permalinks that url_to_postid() misses.
        $path = trim( (string) wp_parse_url( $full_url, PHP_URL_PATH ), '/' );
        if ( $path === '' ) {
            return 0;
        }

        $public_types = get_post_types( array( 'public' => true ), 'names' );

        // Try the full path first (hierarchical pages / CPTs), then the leaf
        // slug (non-hierarchical CPTs like products at the site root).
        $match = get_page_by_path( $path, OBJECT, $public_types );
        if ( ! $match ) {
            $leaf = substr( strrchr( '/' . $path, '/' ), 1 );
            if ( $leaf && $leaf !== $path ) {
                $match = get_page_by_path( $leaf, OBJECT, $public_types );
            }
        }

        return $match ? (int) $match->ID : 0;
    }

    /**
     * Detect whether a URL points to a taxonomy term archive (e.g. a
     * WooCommerce product category, blog category, or tag).
     *
     * Used to give users a clear "not supported yet" message instead of a
     * misleading "URL not found" for archive pages, which have no underlying
     * post to optimize.
     *
     * @param string $url Absolute or relative URL.
     * @return object|false { taxonomy, label, term } or false if not a term.
     */
    public static function detect_taxonomy_for_url( $url ) {
        $url = trim( $url );
        if ( $url === '' ) {
            return false;
        }
        if ( strpos( $url, 'http' ) !== 0 ) {
            $url = home_url( '/' . ltrim( $url, '/' ) );
        }

        $path = trim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' );
        if ( $path === '' ) {
            return false;
        }

        $leaf = substr( strrchr( '/' . $path, '/' ), 1 );
        if ( $leaf === '' ) {
            return false;
        }

        foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $tax ) {
            $term = get_term_by( 'slug', $leaf, $tax->name );
            if ( $term ) {
                return (object) array(
                    'taxonomy' => $tax->name,
                    'label'    => $tax->labels->singular_name,
                    'term'     => $term,
                );
            }
        }

        return false;
    }

    /**
     * Cache for batch-resolved URLs. Maps URL → post_id.
     */
    private static $url_post_cache = array();

    /**
     * Batch-resolve an array of URLs to post IDs.
     *
     * Uses lookup_post_id_by_url() for each URL (url_to_postid() with a
     * public-post-type fallback for root-level CPT permalinks). This is the most reliable approach on
     * multilingual sites (WPML/Polylang) because WPML hooks into url_to_postid()
     * and correctly resolves language-specific URLs — returning the exact post ID
     * whose permalink matches the submitted URL, regardless of the current language
     * context. The previous slug-based DB query + get_permalink() comparison broke
     * on WPML because get_permalink() is language-context-dependent in cron: the
     * English post's get_permalink() returned /da/topic-clusters/ (the default WPML
     * language), so the comparison failed and the Danish post ID was used instead.
     *
     * @param array $urls List of URLs.
     * @return array URL → post_id map.
     */
    public static function batch_resolve_urls( $urls ) {
        self::$url_post_cache = array();

        foreach ( $urls as $url ) {
            $full_url = $url;
            if ( strpos( $url, 'http' ) !== 0 ) {
                $full_url = home_url( '/' . ltrim( $url, '/' ) );
            }
            self::$url_post_cache[ $full_url ] = self::lookup_post_id_by_url( $full_url );
        }

        return self::$url_post_cache;
    }

    /* ───────────────── Editor detection ───────────────── */

    /**
     * Detect which editor a post uses.
     *
     * @param int $post_id WP post ID.
     * @return string One of: elementor, divi, beaver_builder, wpbakery, gutenberg, classic
     */
    public static function detect_editor( $post_id ) {
        $post = get_post( $post_id );
        if ( ! $post ) {
            return 'unknown';
        }

        // Elementor
        if ( get_post_meta( $post_id, '_elementor_edit_mode', true ) === 'builder' ) {
            return 'elementor';
        }

        // Divi
        if ( get_post_meta( $post_id, '_et_pb_use_builder', true ) === 'on' ) {
            return 'divi';
        }

        // Beaver Builder
        if ( get_post_meta( $post_id, '_fl_builder_enabled', true ) ) {
            return 'beaver_builder';
        }

        // SiteOrigin Page Builder
        $panels_data = get_post_meta( $post_id, 'panels_data', true );
        if ( ! empty( $panels_data ) ) {
            return 'siteorigin';
        }

        // Oxygen Builder
        if ( get_post_meta( $post_id, 'ct_builder_shortcodes', true ) || get_post_meta( $post_id, '_ct_builder_json', true ) ) {
            return 'oxygen';
        }

        // Brizy
        if ( get_post_meta( $post_id, 'brizy_post_uid', true ) ) {
            return 'brizy';
        }

        // Thrive Architect
        if ( get_post_meta( $post_id, 'tve_updated_post', true ) ) {
            return 'thrive';
        }

        // Breakdance — stores its tree in _breakdance_data; post_content holds
        // only a launcher block stub. Must run BEFORE the has_blocks() check
        // below, otherwise the stub is misdetected as Gutenberg.
        if ( get_post_meta( $post_id, '_breakdance_data', true ) ) {
            return 'breakdance';
        }

        // WPBakery
        if ( strpos( $post->post_content, '[vc_' ) !== false ) {
            return 'wpbakery';
        }

        // Gutenberg
        if ( has_blocks( $post->post_content ) ) {
            return 'gutenberg';
        }

        // Classic
        return 'classic';
    }

    /**
     * Whether an editor type is supported for optimization.
     */
    public static function is_editor_supported( $editor_type ) {
        return in_array( $editor_type, array( 'classic', 'gutenberg', 'elementor', 'divi', 'wpbakery', 'breakdance', 'customfields' ), true );
    }

    /* ───────────────── SEO plugin support ───────────────── */

    /**
     * Registry of SEO plugins we can read AND write title-tag + meta-description
     * for. Each entry: label, an 'active' flag, and how to read/write its data.
     *
     * 'store' = 'meta'      → individual post meta keys ('title'/'desc').
     * 'store' = 'serialized'→ single post meta key holding an array ('meta'),
     *                          with array keys 'title' and 'desc'.
     *
     * Not included (so they correctly trigger the "unsupported" notice):
     *   • All in One SEO — stores in a custom DB table (wp_aioseo_posts).
     *   • Squirrly       — stores in a custom DB table (qss).
     *   • SmartCrawl     — meta keys unverified.
     * These need dedicated integrations before we can safely write to them.
     *
     * @return array<string,array>
     */
    public static function seo_plugins() {
        return array(
            'yoast' => array(
                'label'  => 'Yoast SEO',
                'active' => defined( 'WPSEO_VERSION' ),
                'store'  => 'meta',
                'title'  => '_yoast_wpseo_title',
                'desc'   => '_yoast_wpseo_metadesc',
            ),
            'rankmath' => array(
                'label'  => 'Rank Math',
                'active' => class_exists( 'RankMath' ),
                'store'  => 'meta',
                'title'  => 'rank_math_title',
                'desc'   => 'rank_math_description',
            ),
            'seopress' => array(
                'label'  => 'SEOPress',
                'active' => defined( 'SEOPRESS_VERSION' ),
                'store'  => 'meta',
                'title'  => '_seopress_titles_title',
                'desc'   => '_seopress_titles_desc',
            ),
            'surerank' => array(
                'label'  => 'SureRank',
                'active' => defined( 'SURERANK_FILE' ),
                'store'  => 'meta',
                'title'  => 'surerank_settings_page_title',
                'desc'   => 'surerank_settings_page_description',
            ),
            'tsf' => array(
                'label'  => 'The SEO Framework',
                'active' => defined( 'THE_SEO_FRAMEWORK_VERSION' ),
                'store'  => 'meta',
                'title'  => '_genesis_title',
                'desc'   => '_genesis_description',
            ),
            'slimseo' => array(
                'label'  => 'Slim SEO',
                'active' => defined( 'SLIM_SEO_VER' ),
                'store'  => 'serialized',
                'meta'   => 'slim_seo',
            ),
        );
    }

    /**
     * Return the active supported SEO plugin definition, or null if none.
     *
     * @return array|null { key, label, store, ... }
     */
    public static function get_active_seo_plugin() {
        foreach ( self::seo_plugins() as $key => $plugin ) {
            if ( ! empty( $plugin['active'] ) ) {
                $plugin['key'] = $key;
                return $plugin;
            }
        }
        return null;
    }

    /**
     * Read the SEO title + meta description for a post or term from the active
     * plugin.
     *
     * @param int    $object_id   WP post ID, or term ID when $object_type='term'.
     * @param string $object_type 'post' (default) or 'term'.
     * @param string $taxonomy    Taxonomy name (required for Yoast term reads).
     * @return array { title: string, description: string } ('' when not set).
     */
    public static function read_seo_meta( $object_id, $object_type = 'post', $taxonomy = '' ) {
        $plugin = self::get_active_seo_plugin();
        $title  = '';
        $desc   = '';

        if ( $plugin ) {
            $vals  = $object_type === 'term'
                ? self::read_term_seo( $plugin, $object_id, $taxonomy )
                : self::read_post_seo( $plugin, $object_id );
            $title = $vals['title'];
            $desc  = $vals['desc'];
        }

        // Resolve SEO placeholder tokens (%%title%%, %title%, etc.) for posts.
        // Term values are passed through unchanged — the placeholder resolver is
        // post-context only and would mis-substitute term tokens.
        if ( $object_type === 'term' ) {
            return array( 'title' => $title, 'description' => $desc );
        }

        return array(
            'title'       => self::resolve_seo_placeholders( $title, $object_id ),
            'description' => self::resolve_seo_placeholders( $desc, $object_id ),
        );
    }

    /**
     * Write an optimized SEO title and/or meta description back to the active
     * supported SEO plugin. No-op (returns false) when no supported plugin is
     * active — callers should have warned the user up front.
     *
     * @param int    $object_id   WP post ID, or term ID when $object_type='term'.
     * @param string $title_tag   Optimized title tag ('' to skip).
     * @param string $meta_desc   Optimized meta description ('' to skip).
     * @param string $object_type 'post' (default) or 'term'.
     * @param string $taxonomy    Taxonomy name (required for Yoast term writes).
     * @return bool True if a supported plugin received the data.
     */
    public static function write_seo_meta( $object_id, $title_tag = '', $meta_desc = '', $object_type = 'post', $taxonomy = '' ) {
        $plugin = self::get_active_seo_plugin();
        if ( ! $plugin ) {
            return false;
        }

        if ( $object_type === 'term' ) {
            self::write_term_seo( $plugin, $object_id, $taxonomy, $title_tag, $meta_desc );
        } else {
            self::write_post_seo( $plugin, $object_id, $title_tag, $meta_desc );
        }

        return true;
    }

    /* ---- post-level storage ---- */

    private static function read_post_seo( $plugin, $post_id ) {
        if ( $plugin['store'] === 'serialized' ) {
            $data = get_post_meta( $post_id, $plugin['meta'], true );
            $data = is_array( $data ) ? $data : array();
            return array(
                'title' => isset( $data['title'] ) ? (string) $data['title'] : '',
                'desc'  => isset( $data['description'] ) ? (string) $data['description'] : '',
            );
        }
        return array(
            'title' => (string) get_post_meta( $post_id, $plugin['title'], true ),
            'desc'  => (string) get_post_meta( $post_id, $plugin['desc'], true ),
        );
    }

    private static function write_post_seo( $plugin, $post_id, $title_tag, $meta_desc ) {
        if ( $plugin['store'] === 'serialized' ) {
            $data = get_post_meta( $post_id, $plugin['meta'], true );
            $data = is_array( $data ) ? $data : array();
            if ( $title_tag !== '' ) {
                $data['title'] = $title_tag;
            }
            if ( $meta_desc !== '' ) {
                $data['description'] = $meta_desc;
            }
            update_post_meta( $post_id, $plugin['meta'], $data );
            return;
        }
        if ( $title_tag !== '' ) {
            update_post_meta( $post_id, $plugin['title'], $title_tag );
        }
        if ( $meta_desc !== '' ) {
            update_post_meta( $post_id, $plugin['desc'], $meta_desc );
        }
    }

    /* ---- term-level storage ----
     * Terms store SEO meta differently than posts:
     *   • Rank Math / SEOPress / SureRank — same key names, but via term meta.
     *   • Slim SEO — serialized term meta under the same 'slim_seo' key.
     *   • Yoast    — a single site option 'wpseo_taxonomy_meta'[tax][term]['wpseo_title'|'wpseo_desc'].
     *   • TSF      — serialized term meta under THE_SEO_FRAMEWORK_TERM_OPTIONS ('doctitle'|'description').
     */

    private static function read_term_seo( $plugin, $term_id, $taxonomy ) {
        switch ( $plugin['key'] ) {
            case 'yoast':
                $opt  = get_option( 'wpseo_taxonomy_meta', array() );
                $meta = isset( $opt[ $taxonomy ][ $term_id ] ) ? $opt[ $taxonomy ][ $term_id ] : array();
                return array(
                    'title' => isset( $meta['wpseo_title'] ) ? (string) $meta['wpseo_title'] : '',
                    'desc'  => isset( $meta['wpseo_desc'] ) ? (string) $meta['wpseo_desc'] : '',
                );

            case 'tsf':
                $data = get_term_meta( $term_id, THE_SEO_FRAMEWORK_TERM_OPTIONS, true );
                $data = is_array( $data ) ? $data : array();
                return array(
                    'title' => isset( $data['doctitle'] ) ? (string) $data['doctitle'] : '',
                    'desc'  => isset( $data['description'] ) ? (string) $data['description'] : '',
                );

            case 'slimseo':
                $data = get_term_meta( $term_id, $plugin['meta'], true );
                $data = is_array( $data ) ? $data : array();
                return array(
                    'title' => isset( $data['title'] ) ? (string) $data['title'] : '',
                    'desc'  => isset( $data['description'] ) ? (string) $data['description'] : '',
                );

            default: // rankmath, seopress, surerank — same keys via term meta
                return array(
                    'title' => (string) get_term_meta( $term_id, $plugin['title'], true ),
                    'desc'  => (string) get_term_meta( $term_id, $plugin['desc'], true ),
                );
        }
    }

    private static function write_term_seo( $plugin, $term_id, $taxonomy, $title_tag, $meta_desc ) {
        switch ( $plugin['key'] ) {
            case 'yoast':
                $opt = get_option( 'wpseo_taxonomy_meta', array() );
                if ( ! isset( $opt[ $taxonomy ] ) ) {
                    $opt[ $taxonomy ] = array();
                }
                if ( ! isset( $opt[ $taxonomy ][ $term_id ] ) ) {
                    $opt[ $taxonomy ][ $term_id ] = array();
                }
                if ( $title_tag !== '' ) {
                    $opt[ $taxonomy ][ $term_id ]['wpseo_title'] = $title_tag;
                }
                if ( $meta_desc !== '' ) {
                    $opt[ $taxonomy ][ $term_id ]['wpseo_desc'] = $meta_desc;
                }
                update_option( 'wpseo_taxonomy_meta', $opt );
                break;

            case 'tsf':
                $data = get_term_meta( $term_id, THE_SEO_FRAMEWORK_TERM_OPTIONS, true );
                $data = is_array( $data ) ? $data : array();
                if ( $title_tag !== '' ) {
                    $data['doctitle'] = $title_tag;
                }
                if ( $meta_desc !== '' ) {
                    $data['description'] = $meta_desc;
                }
                update_term_meta( $term_id, THE_SEO_FRAMEWORK_TERM_OPTIONS, $data );
                break;

            case 'slimseo':
                $data = get_term_meta( $term_id, $plugin['meta'], true );
                $data = is_array( $data ) ? $data : array();
                if ( $title_tag !== '' ) {
                    $data['title'] = $title_tag;
                }
                if ( $meta_desc !== '' ) {
                    $data['description'] = $meta_desc;
                }
                update_term_meta( $term_id, $plugin['meta'], $data );
                break;

            default: // rankmath, seopress, surerank — same keys via term meta
                if ( $title_tag !== '' ) {
                    update_term_meta( $term_id, $plugin['title'], $title_tag );
                }
                if ( $meta_desc !== '' ) {
                    update_term_meta( $term_id, $plugin['desc'], $meta_desc );
                }
                break;
        }
    }

    /* ───────────────── Post data for API ───────────────── */

    /**
     * Gather all data the Rank Booster API needs from a WP post.
     *
     * @param int    $post_id        WP post ID.
     * @param string $focus_keywords Comma-separated keywords.
     * @param string $url            The original URL submitted by the user.
     *                               When provided, used as url_post instead of
     *                               get_permalink() which is language-context-dependent
     *                               on WPML/Polylang sites and may return the wrong language URL.
     * @return array|WP_Error
     */
    public static function get_post_data_for_api( $post_id, $focus_keywords = '', $url = '', $editor_type = '' ) {
        $post = get_post( $post_id );
        if ( ! $post ) {
            return new WP_Error( 'no_post', 'Post not found.' );
        }

        // Read the existing SEO title tag + meta description from the active
        // supported SEO plugin. Falls back to the WP post title and a content
        // snippet when no supported plugin is detected.
        // read_seo_meta() already resolves SEO placeholder tokens (%%title%%,
        // %title%, etc.); the fallbacks below are plain text.
        $seo   = self::read_seo_meta( $post_id );
        $title = $seo['title'] !== '' ? $seo['title'] : get_the_title( $post_id );
        $meta  = $seo['description'] !== '' ? $seo['description'] : wp_trim_words( $post->post_content, 30, '...' );

        // Use the editor type resolved at precheck (passed in by the job) when
        // available. This is essential for 'customfields', which is decided by
        // the render-probe gate at precheck and is NOT returned by detect_editor().
        // Falling back to detect_editor() here would wrongly route a custom-field
        // page back to the post_content path.
        if ( empty( $editor_type ) ) {
            $editor_type = self::detect_editor( $post_id );
        }

        // For Elementor: extract text slots and serialize as HTML.
        // The API uses editor_type='elementor' to tell the LLM to preserve
        // the <div id="..."> slot wrappers and only improve inner text.
        // Also detect the actual visual H1 (heading widget with header_size='h1')
        // rather than using the WP post title, which Elementor often hides.
        if ( $editor_type === 'elementor' ) {
            $slots        = Rank_Booster_Elementor::extract_text_slots( $post_id );
            $content_html = Rank_Booster_Elementor::slots_to_html( $slots );
            $h1_slot      = Rank_Booster_Elementor::get_h1_slot( $post_id );
            $h1_header    = $h1_slot ? $h1_slot['text'] : get_the_title( $post_id );
        } elseif ( $editor_type === 'breakdance' ) {
            // Breakdance content lives in the _breakdance_data JSON tree, not
            // post_content. Extract editable prose into the same slot-HTML
            // convention Elementor uses.
            $slots        = Rank_Booster_Breakdance::extract_slots( $post_id );
            $content_html = Rank_Booster_Breakdance::slots_to_html( $slots );
            $h1_header    = get_the_title( $post_id );
            if ( get_option( 'rank_debug_enabled', false ) && class_exists( 'Rank_Processor' ) ) {
                Rank_Processor::add_debug_log( '[Rank Booster] Breakdance extract: post=' . $post_id . ' slots=' . count( $slots ) . ' content_len=' . strlen( $content_html ) );
            }
        } elseif ( $editor_type === 'customfields' ) {
            // Visible content lives in ACF/postmeta fields. Select the fields
            // that render in the page's main content and serialize them as slots.
            $probe_url    = ! empty( $url ) ? $url : get_permalink( $post_id );
            $candidates   = Rank_Booster_CustomFields::gather_candidates( $post_id );
            $selected     = Rank_Booster_CustomFields::select_visible_fields( $candidates, $probe_url );
            $content_html = Rank_Booster_CustomFields::slots_to_html( $selected );
            $h1_header    = get_the_title( $post_id );
            if ( get_option( 'rank_debug_enabled', false ) && class_exists( 'Rank_Processor' ) ) {
                Rank_Processor::add_debug_log( '[Rank Booster] CustomFields extract: post=' . $post_id . ' candidates=' . count( $candidates ) . ' selected=' . count( $selected ) . ' content_len=' . strlen( $content_html ) );
            }
        } else {
            $content_html = $post->post_content;
            $h1_header    = get_the_title( $post_id );
        }

        // Build API payload.
        // Use the original submitted URL when provided — get_permalink() is language-context-
        // dependent on WPML/Polylang and returns the current-language URL, not the submitted one.
        $payload = array(
            'url_post'          => ! empty( $url ) ? $url : get_permalink( $post_id ),
            'meta_post'         => $meta,
            'title_post'        => $title,
            'h1_header'         => $h1_header,
            'publish_date_post' => $post->post_date,
            'update_date_post'  => $post->post_modified,
            'focus_keywords'    => $focus_keywords,
            'post_content_html' => $content_html,
        );

        // Tell the API which structure to preserve. The API only has special
        // handling for elementor / gutenberg / divi, so we map the new builders
        // onto the wire format that matches their content shape:
        //   • breakdance → 'elementor' (identical <div id="..."> slot wrappers)
        //   • wpbakery   → 'divi'      (shortcode-based content to preserve)
        // Our internal job.editor_type stays the real value for routing.
        $api_wire = array(
            'elementor' => 'elementor',
            'gutenberg' => 'gutenberg',
            'divi'      => 'divi',
            'breakdance' => 'elementor',
            'wpbakery'  => 'divi',
            'customfields' => 'elementor',
        );
        if ( isset( $api_wire[ $editor_type ] ) ) {
            $payload['editor_type'] = $api_wire[ $editor_type ];
        }

        return $payload;
    }

    /**
     * Gather the data the Rank Booster API needs from a taxonomy term
     * (e.g. a WooCommerce product category).
     *
     * Terms only support title-tag + meta-description optimization, plus a
     * content rewrite of the term description (when it's non-empty). There is
     * no editor_type — the term description is treated as plain HTML content.
     *
     * @param int    $term_id        Term ID.
     * @param string $focus_keywords Comma-separated keywords.
     * @param string $url            The original URL submitted by the user.
     * @return array|WP_Error
     */
    public static function get_term_data_for_api( $term_id, $focus_keywords = '', $url = '' ) {
        $term = get_term( (int) $term_id );
        if ( ! $term || is_wp_error( $term ) ) {
            return new WP_Error( 'no_term', 'Term not found.' );
        }

        $seo         = self::read_seo_meta( $term->term_id, 'term', $term->taxonomy );
        $description  = (string) $term->description;
        $title       = $seo['title'] !== '' ? $seo['title'] : $term->name;
        $meta        = $seo['description'] !== '' ? $seo['description'] : wp_trim_words( $description, 30, '...' );

        // No editor_type for terms — the term description is plain HTML content.
        return array(
            'url_post'          => ! empty( $url ) ? $url : get_term_link( $term ),
            'meta_post'         => $meta,
            'title_post'        => $title,
            'h1_header'         => $term->name,
            'focus_keywords'    => $focus_keywords,
            'post_content_html' => $description,
        );
    }

    /**
     * Whether a term description holds content we can optimize.
     *
     * Drives the term flow:
     *   • Non-empty → full automatic + manual flow (content checks have something
     *     to work on).
     *   • Empty → limited (manual) flow with title tag + meta description only,
     *     since automatic orchestration would run content checks against nothing
     *     and the visible content lives in a builder/theme template we can't reach.
     *
     * @param int $term_id Term ID.
     * @return bool
     */
    public static function term_has_content( $term_id ) {
        $term = get_term( (int) $term_id );
        return $term && ! is_wp_error( $term ) && trim( (string) $term->description ) !== '';
    }

    /**
     * Resolve SEO-plugin placeholder tokens in a title/meta string.
     *
     * Different plugins use different syntaxes:
     *   Yoast    %%var%%      RankMath %var%      AIOSEO  #var
     *
     * Strategy (KISS): if the string contains placeholders, try the active
     * plugin's own replacer (Yoast / RankMath). If tokens still remain
     * (AIOSEO/SEOPress, or replacer unavailable), fall back to the post title.
     *
     * @param string $string  Raw template string.
     * @param int    $post_id WP post ID.
     * @return string
     */
    private static function resolve_seo_placeholders( $string, $post_id ) {
        $pattern = '/%%?[a-z0-9_-]+%?%|#[a-z0-9_]+/i';

        if ( ! is_string( $string ) || $string === '' || ! preg_match( $pattern, $string ) ) {
            return $string;
        }

        $post = get_post( $post_id );

        if ( defined( 'WPSEO_VERSION' ) && function_exists( 'wpseo_replace_vars' ) ) {
            $string = wpseo_replace_vars( $string, $post );
        } elseif ( class_exists( 'RankMath\Helper' ) && method_exists( 'RankMath\Helper', 'replace_vars' ) ) {
            $string = RankMath\Helper::replace_vars( $string, $post );
        }

        // If placeholders still remain (AIOSEO/SEOPress or no replacer), drop the
        // template entirely and use the real post title.
        if ( preg_match( $pattern, $string ) ) {
            $string = get_the_title( $post_id );
        }

        return $string;
    }

    /* ───────────────── Rank Booster API calls ───────────────── */

    /**
     * POST /analyze
     *
     * @param array $post_data Base schema data.
     * @param bool  $orchestrate Whether to auto-run all checks.
     * @return array|WP_Error
     */
    public static function call_analyze( $post_data, $orchestrate = false ) {
        $body = $post_data;
        if ( $orchestrate ) {
            $body['orchestrate'] = true;
        }

        return self::api_post( '/analyze', $body );
    }

    /**
     * GET /status/:jobId
     *
     * @param string $job_id API job ID.
     * @return array|WP_Error
     */
    public static function call_job_status( $job_id ) {
        return self::api_get( '/status/' . $job_id );
    }

    /**
     * Call an individual improvement endpoint.
     *
     * @param string $check     Check slug, e.g. 'quick_summary'.
     * @param array  $post_data Base schema data.
     * @param string $analysis_summary Optional context from analysis.
     * @return array|WP_Error
     */
    public static function call_individual_check( $check, $post_data, $analysis_summary = '' ) {
        $endpoint = self::get_check_endpoint( $check );
        if ( is_wp_error( $endpoint ) ) {
            return $endpoint;
        }

        $body = $post_data;
        if ( $analysis_summary ) {
            $body['analysis_summary'] = $analysis_summary;
        }

        return self::api_post( $endpoint, $body );
    }

    public static function call_individual_check_async( $check, $post_data, $analysis_summary = '' ) {
        $endpoint = self::get_check_endpoint( $check );
        if ( is_wp_error( $endpoint ) ) {
            return $endpoint;
        }

        $body = $post_data;
        $body['async'] = true;
        if ( $analysis_summary ) {
            $body['analysis_summary'] = $analysis_summary;
        }

        return self::api_post( $endpoint, $body );
    }

    private static function get_check_endpoint( $check ) {
        $slug_map = array(
            'quick_summary'            => '/check/quick-summary',
            'focus_keywords'           => '/check/focus-keywords',
            'improve_header'           => '/check/improve-header',
            'content_rewrite'          => '/check/content-rewrite',
            'content_outdated'         => '/check/content-outdated',
            'eeat_signals'             => '/check/eeat-signals',
            'external_links_checker'   => '/check/external-links-checker',
            'add_faq'                  => '/check/add-faq',
            'fix_grammar'              => '/check/fix-grammar',
            'improve_title_tag'        => '/check/improve-title-tag',
            'improve_meta_description' => '/check/improve-meta-description',
        );

        if ( ! isset( $slug_map[ $check ] ) ) {
            return new WP_Error( 'unknown_check', "Unknown check: $check" );
        }

        return $slug_map[ $check ];
    }

    /* ───────────────── Focus keywords ───────────────── */

    /**
     * Fetch up to 3 focus keywords from the tracked-keywords API for a given URL.
     *
     * @param string $url The post URL.
     * @return array Array of keyword objects with kw, sv, cpc, position.
     */
    public static function get_focus_keywords_for_url( $url ) {
        $result = self::get_bulk_focus_keywords( array( $url ) );
        return isset( $result[ $url ] ) ? $result[ $url ] : array();
    }

    /**
     * Fetch tracked keywords for multiple URLs, respecting WPML language per URL.
     *
     * Each URL's WPML language is detected individually. URLs are grouped by their
     * detected language and each group's keywords are fetched from the matching
     * Morningscore domain identifier (matched by `hl` field). This means a batch
     * containing both English and Danish URLs will correctly query the en and da
     * domain identifiers respectively, rather than using a single global context.
     *
     * @param array $urls List of URLs to find keywords for.
     * @return array URL => array of keyword objects (max 3 per URL, sorted by position).
     */
    public static function get_bulk_focus_keywords( $urls ) {
        $debug   = get_option( 'rank_debug_enabled', false );
        $is_wpml = defined( 'WPML_PLUGIN_PATH' ) || function_exists( 'icl_get_languages' );

        if ( $debug ) {
            Rank_Processor::add_debug_log( '[FocusKW] === bulk_focus_keywords START === URLs=' . count( $urls ) . ' WPML=' . ( $is_wpml ? 'yes' : 'no' ) );
        }

        // ── Step 1: detect WPML language per URL ──────────────────────────────
        $url_lang = array();

        if ( $is_wpml ) {
            $fallback_lang = apply_filters( 'wpml_admin_language', null )
                          ?: apply_filters( 'wpml_current_language', null )
                          ?: 'en';

            foreach ( $urls as $url ) {
                $lang = apply_filters( 'wpml_url_language_code', null, $url );
                if ( ! $lang ) {
                    $lang = $fallback_lang;
                }
                $url_lang[ $url ] = $lang;
                if ( $debug ) {
                    Rank_Processor::add_debug_log( '[FocusKW] URL lang: "' . $url . '" → ' . $lang );
                }
            }
        } else {
            foreach ( $urls as $url ) {
                $url_lang[ $url ] = '__default__';
            }
        }

        // ── Step 2: group URLs by language ────────────────────────────────────
        $lang_groups = array();
        foreach ( $url_lang as $url => $lang ) {
            $lang_groups[ $lang ][] = $url;
        }

        if ( $debug ) {
            foreach ( $lang_groups as $lang => $group_urls ) {
                Rank_Processor::add_debug_log( '[FocusKW] Language group "' . $lang . '": ' . count( $group_urls ) . ' URL(s)' );
            }
        }

        // ── Step 3: resolve domain identifier per language group ──────────────
        $lang_to_domain = array();
        foreach ( array_keys( $lang_groups ) as $lang ) {
            $lookup_lang = ( $lang === '__default__' ) ? null : $lang;
            $identifier  = rank_get_domain_identifier_for_lang( $lookup_lang );

            if ( is_wp_error( $identifier ) ) {
                Rank_Processor::add_debug_log( '[FocusKW] domain_identifier ERROR for lang="' . $lang . '": ' . $identifier->get_error_message() );
                $lang_to_domain[ $lang ] = false;
            } else {
                $lang_to_domain[ $lang ] = $identifier;
                if ( $debug ) {
                    Rank_Processor::add_debug_log( '[FocusKW] Language "' . $lang . '" → domain_identifier=' . $identifier );
                }
            }
        }

        // ── Step 4: fetch keywords per domain identifier ──────────────────────
        // De-duplicate: multiple language groups may map to the same identifier
        $url_keywords       = array();
        $identifier_to_urls = array();

        foreach ( $lang_groups as $lang => $group_urls ) {
            $identifier = $lang_to_domain[ $lang ];
            if ( ! $identifier ) {
                continue;
            }
            foreach ( $group_urls as $url ) {
                $identifier_to_urls[ $identifier ][] = $url;
            }
        }

        foreach ( $identifier_to_urls as $domain_identifier => $group_urls ) {
            $path_to_urls = array();
            foreach ( $group_urls as $url ) {
                $path                    = rtrim( (string) parse_url( $url, PHP_URL_PATH ), '/' );
                $path_to_urls[ $path ][] = $url;
            }

            if ( $debug ) {
                Rank_Processor::add_debug_log( '[FocusKW] Fetching domain_identifier=' . $domain_identifier . ' for ' . count( $group_urls ) . ' URL(s)' );
                foreach ( $group_urls as $u ) {
                    Rank_Processor::add_debug_log( '[FocusKW]   path="' . rtrim( (string) parse_url( $u, PHP_URL_PATH ), '/' ) . '" url="' . $u . '"' );
                }
            }

            $page          = 1;
            $per_page      = 1000;
            $max_pages     = 10;
            $total_fetched = 0;

            while ( $page <= $max_pages ) {
                $api_url = "https://api.morningscore.io/v1/{$domain_identifier}/tracked-keywords?" . http_build_query( array(
                    'pagination.page'     => $page,
                    'pagination.per_page' => $per_page,
                ) );

                if ( $debug ) {
                    Rank_Processor::add_debug_log( '[FocusKW] Fetching page ' . $page . ': ' . $api_url );
                }

                $response = wp_remote_get( $api_url, array(
                    'headers' => array(
                        'Authorization' => 'Bearer ' . rank_get_api_key(),
                        'Accept'        => 'application/json',
                    ),
                    'timeout'    => 30,
                    'user-agent' => 'Mozilla/5.0 (compatible; RANK AI/1.0; +https://morningscore.io)',
                ) );

                if ( is_wp_error( $response ) ) {
                    Rank_Processor::add_debug_log( '[FocusKW] API error page ' . $page . ' (domain=' . $domain_identifier . '): ' . $response->get_error_message() );
                    break;
                }

                $http_code = wp_remote_retrieve_response_code( $response );
                $raw_body  = wp_remote_retrieve_body( $response );
                $body      = json_decode( $raw_body, true );

                if ( ! is_array( $body ) ) {
                    Rank_Processor::add_debug_log( '[FocusKW] Page ' . $page . ' HTTP=' . $http_code . ' non-array body. Raw (first 300): ' . mb_substr( $raw_body, 0, 300 ) );
                    break;
                }

                $data = isset( $body['data'] ) ? $body['data'] : array();
                if ( empty( $data ) ) {
                    if ( $debug ) {
                        Rank_Processor::add_debug_log( '[FocusKW] Page ' . $page . ' HTTP=' . $http_code . ' empty data, stopping.' );
                    }
                    break;
                }

                // Determine total for pagination
                $total        = 0;
                $total_source = 'none';
                if ( isset( $body['total'] ) ) {
                    $total        = (int) $body['total'];
                    $total_source = 'body.total';
                } elseif ( isset( $body['meta']['total'] ) ) {
                    $total        = (int) $body['meta']['total'];
                    $total_source = 'body.meta.total';
                } elseif ( isset( $body['meta']['last_page'] ) ) {
                    $total        = (int) $body['meta']['last_page'] * 100;
                    $total_source = 'body.meta.last_page*100 (estimate)';
                }

                $total_fetched += count( $data );

                if ( $debug ) {
                    Rank_Processor::add_debug_log( '[FocusKW] Page ' . $page . ' HTTP=' . $http_code . ': ' . count( $data ) . ' rows | total=' . $total . ' (' . $total_source . ') | total_fetched=' . $total_fetched );
                    if ( $page === 1 ) {
                        Rank_Processor::add_debug_log( '[FocusKW] Sample keywords (first 5):' );
                        foreach ( array_slice( $data, 0, 5 ) as $s ) {
                            Rank_Processor::add_debug_log( '[FocusKW]   kw="' . ( $s['kw'] ?? '?' ) . '" landing_page="' . ( $s['landing_page'] ?? '' ) . '" full_landing_page="' . ( $s['full_landing_page'] ?? '' ) . '" position=' . ( $s['position'] ?? '?' ) );
                        }
                    }
                }

                foreach ( $data as $kw ) {
                    $landing_url = '';
                    if ( ! empty( $kw['full_landing_page'] ) ) {
                        $landing_url = $kw['full_landing_page'];
                    } elseif ( ! empty( $kw['landing_page'] ) ) {
                        $landing_url = $kw['landing_page'];
                    }
                    $landing_path = rtrim( (string) parse_url( $landing_url, PHP_URL_PATH ), '/' );

                    if ( isset( $path_to_urls[ $landing_path ] ) ) {
                        foreach ( $path_to_urls[ $landing_path ] as $matched_url ) {
                            $url_keywords[ $matched_url ][] = $kw;
                        }
                    }
                }

                if ( $total > 0 && $total_fetched >= $total ) {
                    if ( $debug ) {
                        Rank_Processor::add_debug_log( '[FocusKW] Pagination stop: total_fetched=' . $total_fetched . ' >= total=' . $total );
                    }
                    break;
                }
                if ( count( $data ) < $per_page ) {
                    if ( $debug ) {
                        Rank_Processor::add_debug_log( '[FocusKW] Pagination stop: last page (' . count( $data ) . ' rows < per_page=' . $per_page . ')' );
                    }
                    break;
                }
                $page++;
            }

            if ( $debug ) {
                Rank_Processor::add_debug_log( '[FocusKW] Done domain=' . $domain_identifier . ': ' . $total_fetched . ' keywords scanned, ' . $page . ' page(s)' );
            }
        }

        // ── Step 5: sort and return top 3 per URL ─────────────────────────────
        $result = array();
        foreach ( $urls as $url ) {
            $kws = isset( $url_keywords[ $url ] ) ? $url_keywords[ $url ] : array();

            usort( $kws, function( $a, $b ) {
                return ( $a['position'] ?? 999 ) - ( $b['position'] ?? 999 );
            } );

            $top = array();
            foreach ( array_slice( $kws, 0, 3 ) as $kw ) {
                $top[] = array(
                    'kw'       => $kw['kw'],
                    'sv'       => isset( $kw['sv'] ) ? $kw['sv'] : 0,
                    'cpc'      => isset( $kw['cpc'] ) ? $kw['cpc'] : 0,
                    'position' => isset( $kw['position'] ) ? $kw['position'] : 0,
                );
            }

            if ( $debug ) {
                $top_kws = implode( ', ', array_column( $top, 'kw' ) );
                Rank_Processor::add_debug_log( '[FocusKW] Result "' . $url . '" (lang=' . ( $url_lang[ $url ] ?? '?' ) . '): ' . count( $top ) . ' kw(s) → ' . ( $top_kws ?: '(none)' ) );
            }

            $result[ $url ] = $top;
        }

        if ( $debug ) {
            Rank_Processor::add_debug_log( '[FocusKW] === bulk_focus_keywords END ===' );
        }

        return $result;
    }

    /* ───────────────── Check categories ───────────────── */

    /**
     * Return the list of all optimisation check categories.
     */
    public static function get_check_categories() {
        return array(
            'quick_summary'            => 'Add Key Takeaways',
            'focus_keywords'           => 'Optimize Keywords',
            'improve_header'           => 'Improve H1',
            'content_rewrite'          => 'Rewrite Content',
            'content_outdated'         => 'Update Outdated Content',
            'eeat_signals'             => 'Add E-E-A-T Signals',
            'external_links_checker'   => 'Check External Links',
            'add_faq'                  => 'Add FAQ',
            'fix_grammar'              => 'Fix Grammar',
            'improve_title_tag'        => 'Improve Title Tag',
            'improve_meta_description' => 'Improve Meta Description',
        );
    }

    /* ───────────────── Internal HTTP helpers ───────────────── */

    private static function api_post( $endpoint, $body ) {
        $response = wp_remote_post( self::API_BASE . $endpoint, array(
            'headers' => array(
                'Authorization' => 'Bearer ' . rank_get_api_key(),
                'Content-Type'  => 'application/json',
                'Accept'        => 'application/json',
            ),
            'body'       => wp_json_encode( $body ),
            'timeout'    => 120,
            'user-agent' => 'Mozilla/5.0 (compatible; RANK AI/1.0; +https://morningscore.io)',
        ) );

        return self::parse_response( $response );
    }

    private static function api_get( $endpoint ) {
        $response = wp_remote_get( self::API_BASE . $endpoint, array(
            'headers' => array(
                'Authorization' => 'Bearer ' . rank_get_api_key(),
                'Accept'        => 'application/json',
            ),
            'timeout'    => 30,
            'user-agent' => 'Mozilla/5.0 (compatible; RANK AI/1.0; +https://morningscore.io)',
        ) );

        return self::parse_response( $response );
    }

    private static function parse_response( $response ) {
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        $raw_body = wp_remote_retrieve_body( $response );
        $body = json_decode( $raw_body, true );

        if ( $code >= 400 ) {
            if ( class_exists( 'Rank_Processor' ) ) {
                Rank_Processor::add_debug_log( '[Rank Booster] API error: HTTP ' . $code . ' response=' . mb_substr( $raw_body, 0, 2000 ) );
            }
            $msg = isset( $body['error'] ) ? $body['error'] : "API error (HTTP $code)";
            return new WP_Error( 'api_error', $msg, array( 'status' => $code, 'body' => $body ) );
        }

        return is_array( $body ) ? $body : array();
    }
}
