<?php
/**
 * Rank Booster AJAX Handlers
 *
 * Every wp_ajax_ action for the Rank Booster tab.
 */

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

class Rank_Booster_Ajax {

    /**
     * How long (seconds) a single job may sit at 'processing' with no activity
     * before it's considered stuck and recovered. Measured as time since the
     * job last logged anything (updated_at). During healthy processing the job
     * bumps updated_at every ~5s (polling) and the longest legitimate gap is the
     * single /analyze call (max 120s), so 7 min is a safe margin with no false
     * positives. This is PER job, not for the whole multi-job session.
     */
    const STUCK_JOB_TIMEOUT = 420;

    public static function init() {
        $actions = array(
            'rank_booster_prepare_urls',
            'rank_booster_start_optimization',
            'rank_booster_get_session_status',
            'rank_booster_abort_session',
            'rank_booster_get_active_session',
            'rank_booster_get_preview_url',
            'rank_booster_push_live',
            'rank_booster_edit_draft',
            'rank_booster_get_focus_keywords',
            'rank_booster_get_bulk_focus_keywords',
            'rank_booster_get_session',
            'rank_booster_get_optimized_pages',
            'rank_booster_get_credits',
            'rank_booster_export_csv',
        );

        foreach ( $actions as $action ) {
            $method = str_replace( 'rank_booster_', 'ajax_', $action );
            add_action( 'wp_ajax_' . $action, array( __CLASS__, $method ) );
        }
    }

    /* ───────── auth helper ───────── */

    private static function verify() {
        if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'rank_ajax_nonce' ) ) {
            wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
        }
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
        }
    }

    /* ═══════════════════════════════════════════════
       1. Prepare URLs — resolve posts + detect editors
       ═══════════════════════════════════════════════ */

    public static function ajax_prepare_urls() {
        self::verify();

        $raw = isset( $_POST['urls'] ) ? sanitize_textarea_field( wp_unslash( $_POST['urls'] ) ) : '';
        if ( empty( $raw ) ) {
            wp_send_json_error( array( 'message' => 'No URLs provided.' ) );
        }

        // Split by newline or comma
        $urls = preg_split( '/[\n,]+/', $raw );
        $urls = array_map( 'trim', $urls );
        $urls = array_filter( $urls );
        $urls = array_unique( $urls );

        // Enforce 100-URL limit
        if ( count( $urls ) > 100 ) {
            wp_send_json_error( array( 'message' => 'Maximum 100 URLs per run. You provided ' . count( $urls ) . '.' ) );
        }

        $results = array();

        // Batch-resolve all URLs in a single DB query (fast path), then
        // individual resolve_url_to_post() calls use the populated cache.
        Rank_Booster::batch_resolve_urls( $urls );

        foreach ( $urls as $url ) {
            $post_id = Rank_Booster::resolve_url_to_post( $url );

            if ( ! $post_id ) {
                // Taxonomy/archive pages (e.g. WooCommerce product categories).
                //   • With a description → full flow (automatic + manual), like
                //     any other page. An H1/term-name rewrite is never published
                //     (handled + logged at push-live).
                //   • Without a description → limited (manual) flow, title tag +
                //     meta description only — there's no content to optimize and
                //     automatic orchestration would run content checks on nothing.
                $taxonomy = Rank_Booster::detect_taxonomy_for_url( $url );

                if ( $taxonomy ) {
                    $term_id = (int) $taxonomy->term->term_id;

                    if ( Rank_Booster::term_has_content( $term_id ) ) {
                        $results[] = array(
                            'url'         => $url,
                            'post_id'     => $term_id,
                            'object_type' => 'term',
                            'editor_type' => 'term',
                            'status'      => 'ready',
                            'status_text' => 'Ready',
                            'title'       => $taxonomy->term->name,
                        );
                    } else {
                        $results[] = array(
                            'url'            => $url,
                            'post_id'        => $term_id,
                            'object_type'    => 'term',
                            'editor_type'    => 'term',
                            'status'         => 'limited',
                            'status_text'    => '⚠️ Limited fixes available: ' . $taxonomy->label . ' page has no description to optimize',
                            'title'          => $taxonomy->term->name,
                            'allowed_checks' => array( 'improve_title_tag', 'improve_meta_description' ),
                        );
                    }
                    continue;
                }

                $results[] = array(
                    'url'         => $url,
                    'post_id'     => 0,
                    'editor_type' => null,
                    'status'      => 'not_found',
                    'status_text' => 'URL not found',
                );
                continue;
            }

            $editor = Rank_Booster::detect_editor( $post_id );

            // Custom-fields fallback: when the page has no dedicated builder but
            // the visible content lives in ACF/postmeta (low post_content
            // coverage), switch to the custom-fields provider. Gated by a filter
            // (off by default) so it costs nothing on normal sites.
            if ( in_array( $editor, array( 'classic', 'gutenberg' ), true )
                && class_exists( 'Rank_Booster_CustomFields' )
                && Rank_Booster_CustomFields::enabled()
                && Rank_Booster_CustomFields::should_handle( $post_id, $url )
            ) {
                $editor = 'customfields';
            }
            if ( get_option( 'rank_debug_enabled', false ) && class_exists( 'Rank_Processor' ) ) {
                Rank_Processor::add_debug_log( '[Rank Booster] precheck: url=' . $url . ' post=' . $post_id . ' resolved editor_type=' . $editor );
            }

            if ( ! Rank_Booster::is_editor_supported( $editor ) ) {
                $results[] = array(
                    'url'            => $url,
                    'post_id'        => $post_id,
                    'editor_type'    => $editor,
                    'status'         => 'limited',
                    'status_text'    => '⚠️ Limited fixes available: Page not using Classic editor or Gutenberg',
                    'title'          => get_the_title( $post_id ),
                    'allowed_checks' => array( 'improve_title_tag', 'improve_header', 'improve_meta_description' ),
                );
                continue;
            }

            $results[] = array(
                'url'         => $url,
                'post_id'     => $post_id,
                'editor_type' => $editor,
                'status'      => 'ready',
                'status_text' => 'Ready',
                'title'       => get_the_title( $post_id ),
            );
        }

        wp_send_json_success( array(
            'results'    => $results,
            'categories' => Rank_Booster::get_check_categories(),
            'seo_plugin' => self::get_seo_plugin_notice(),
        ) );
    }

    /**
     * Build the SEO-plugin notice payload for the preparation table.
     *
     * When a supported SEO plugin is active, returns its label so the UI can
     * stay quiet. Otherwise returns a warning message — title-tag and meta-
     * description optimizations will still run, but won't be saved anywhere
     * (no supported, or an unsupported, SEO plugin is installed).
     *
     * @return array { supported: bool, label: string, message: string }
     */
    private static function get_seo_plugin_notice() {
        $active = Rank_Booster::get_active_seo_plugin();

        if ( $active ) {
            return array(
                'supported' => true,
                'label'     => $active['label'],
                'message'   => '',
            );
        }

        return array(
            'supported' => false,
            'label'     => '',
            'message'   => 'No SEO plugin installed, or one that we do not currently support is installed. '
                . 'Title tag &amp; meta description changes won\'t be saved. '
                . 'Plugins like Yoast recommended for full functionality.',
        );
    }

    /* ═══════════════════════════════════════════════
       2. Start Optimisation — save session + schedule
       ═══════════════════════════════════════════════ */

    public static function ajax_start_optimization() {
        self::verify();

        $rows_json = isset( $_POST['rows'] ) ? wp_unslash( $_POST['rows'] ) : '';
        $rows      = json_decode( $rows_json, true );

        $result = Rank_Ms_Chat_Services::booster_start( is_array( $rows ) ? $rows : array() );

        if ( is_wp_error( $result ) ) {
            $status = $result->get_error_data( 'status' ) ?: 400;
            wp_send_json_error( array( 'message' => $result->get_error_message() ), $status );
        }

        wp_send_json_success( $result );
    }

    /**
     * Start booster optimization (shared by AJAX and agent REST).
     *
     * @param array $rows Prepared URL rows.
     * @return array|WP_Error
     */
    public static function start_optimization( $rows ) {

        if ( empty( $rows ) || ! is_array( $rows ) ) {
            return new WP_Error( 'missing_rows', 'No rows provided.', array( 'status' => 400 ) );
        }

        // Validate all custom prompts before inserting any job, so one invalid
        // row cannot leave a partially created optimization session behind.
        foreach ( $rows as $row ) {
            $mode   = ( isset( $row['mode'] ) && $row['mode'] === 'manual' ) ? 'manual' : 'automatic';
            $checks = isset( $row['selected_checks'] ) && is_array( $row['selected_checks'] ) ? $row['selected_checks'] : array();
            if ( $mode !== 'manual' || ! in_array( 'custom_prompt', $checks, true ) ) {
                continue;
            }

            $prompt = sanitize_textarea_field( $row['custom_prompt'] ?? '' );
            if ( $prompt === '' ) {
                return new WP_Error( 'missing_custom_prompt', 'Custom instructions are required.', array( 'status' => 400 ) );
            }
            if ( strlen( $prompt ) > 2000 ) {
                return new WP_Error( 'custom_prompt_too_long', 'Custom instructions must be 2000 characters or fewer.', array( 'status' => 400 ) );
            }
        }

        $active = Rank_Booster_DB::get_active_session();
        if ( $active ) {
            return new WP_Error(
                'session_active',
                'A session is already processing. Please wait or abort it first.',
                array(
                    'status'     => 409,
                    'session_id' => $active->session_id,
                )
            );
        }

        $session_id = wp_generate_uuid4();
        $job_ids    = array();

        foreach ( $rows as $row ) {
            $post_id = isset( $row['post_id'] ) ? absint( $row['post_id'] ) : 0;
            if ( ! $post_id ) {
                continue;
            }

            $object_type = ( isset( $row['object_type'] ) && $row['object_type'] === 'term' ) ? 'term' : 'post';

            $mode = ( isset( $row['mode'] ) && $row['mode'] === 'manual' ) ? 'manual' : 'automatic';

            $selected_checks = null;
            if ( $mode === 'manual' && ! empty( $row['selected_checks'] ) ) {
                $selected_checks = wp_json_encode( array_map( 'sanitize_text_field', $row['selected_checks'] ) );
            }
            $custom_prompt = ( $mode === 'manual'
                && isset( $row['selected_checks'] )
                && is_array( $row['selected_checks'] )
                && in_array( 'custom_prompt', $row['selected_checks'], true ) )
                ? sanitize_textarea_field( $row['custom_prompt'] ?? '' )
                : null;

            $focus_kw = null;
            if ( ! empty( $row['focus_keywords'] ) ) {
                $focus_kw = wp_json_encode( array_map( 'sanitize_text_field', $row['focus_keywords'] ) );
            }

            $credits = $mode === 'automatic' ? 10 : count( $row['selected_checks'] ?? array() ) * 2;

            $update_publish_date = ! empty( $row['update_publish_date'] ) ? 1 : 0;

            $job_id = Rank_Booster_DB::insert_job( array(
                'session_id'          => $session_id,
                'post_id'             => $post_id,
                'object_type'         => $object_type,
                'url'                 => sanitize_text_field( $row['url'] ?? '' ),
                'editor_type'         => sanitize_text_field( $row['editor_type'] ?? 'classic' ),
                'status'              => 'queued',
                'mode'                => $mode,
                'selected_checks'     => $selected_checks,
                'focus_keywords'      => $focus_kw,
                'custom_prompt'       => $custom_prompt,
                'credits_used'        => $credits,
                'update_publish_date' => $update_publish_date,
                'log_entries'         => wp_json_encode( array() ),
            ) );

            if ( $job_id ) {
                $job_ids[] = $job_id;
            }
        }

        if ( empty( $job_ids ) ) {
            return new WP_Error(
                'job_create_failed',
                'Failed to create jobs. Please check that the database table exists.',
                array( 'status' => 500 )
            );
        }

        Rank_Booster_Processor::schedule_session( $session_id );

        return array(
            'session_id' => $session_id,
            'job_count'  => count( $job_ids ),
        );
    }

    /* ═══════════════════════════════════════════════
       3. Get session status — lightweight polling
       ═══════════════════════════════════════════════ */

    public static function ajax_get_session_status() {
        self::verify();

        $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
        $result     = Rank_Ms_Chat_Services::booster_status( $session_id );

        if ( is_wp_error( $result ) ) {
            $status = $result->get_error_data( 'status' ) ?: 400;
            wp_send_json_error( array( 'message' => $result->get_error_message() ), $status );
        }

        wp_send_json_success( $result );
    }

    /**
     * Get booster session status (shared by AJAX and agent REST).
     *
     * @param string $session_id Session UUID.
     * @return array|WP_Error
     */
    public static function get_session_status_data( $session_id ) {
        if ( empty( $session_id ) ) {
            return new WP_Error( 'missing_session', 'No session ID.', array( 'status' => 400 ) );
        }

        $jobs = Rank_Booster_DB::get_jobs_by_session( $session_id );
        if ( ! $jobs ) {
            return new WP_Error( 'session_not_found', 'Session not found.', array( 'status' => 404 ) );
        }

        $all_complete = true;
        $job_data     = array();

        foreach ( $jobs as $job ) {
            $status = self::maybe_auto_mark_pushed_live( $job );

            // ── Stuck-job recovery: if idle at 'processing' too long, mark failed & move on ──
            if ( $status === 'processing' ) {
                // Use current_time('timestamp') (WP local epoch) to match how
                // updated_at is stored (current_time('mysql') = local time).
                // Using time() (UTC) here would skew the age by the site's UTC
                // offset — on UTC+2 it made stuck jobs look ~2h younger, delaying
                // recovery far past the intended window.
                $job_age_seconds = current_time( 'timestamp' ) - strtotime( $job->updated_at );
                if ( $job_age_seconds > self::STUCK_JOB_TIMEOUT ) {
                    $status = self::recover_stuck_job( $job );
                }
            }

            if ( in_array( $status, array( 'queued', 'processing' ), true ) ) {
                $all_complete = false;
            }

            $job_data[] = array(
                'id'          => (int) $job->id,
                'url'         => $job->url,
                'post_id'     => (int) $job->post_id,
                'object_type' => isset( $job->object_type ) ? $job->object_type : 'post',
                'editor_type' => $job->editor_type,
                'status'      => $status,
                'mode'        => $job->mode,
                'credits_used' => (int) $job->credits_used,
                'log_entries' => $job->log_entries ? json_decode( $job->log_entries, true ) : array(),
                'has_draft'   => self::job_has_draft( $job ),
                'error'       => $job->error_message,
            );
        }

        $credits_info = self::get_credits_info();

        return array(
            'session_id'   => $session_id,
            'jobs'         => $job_data,
            'all_complete' => $all_complete,
            'credits'      => $credits_info,
        );
    }

    /* ═══════════════════════════════════════════════
       4. Abort session
       ═══════════════════════════════════════════════ */

    public static function ajax_abort_session() {
        self::verify();

        $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
        if ( empty( $session_id ) ) {
            wp_send_json_error( array( 'message' => 'No session ID.' ) );
        }

        // Cancel pending Action Scheduler actions for this session
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( 'rank_booster_process_page', null, 'rank_booster_' . $session_id );
            as_unschedule_all_actions( 'rank_booster_poll_job', null, 'rank_booster_' . $session_id );
        }

        // Mark remaining jobs as aborted
        $affected = Rank_Booster_DB::abort_session( $session_id );

        // Stop the background watchdog now that this session is no longer active.
        Rank_Booster_Processor::maybe_stop_watchdog();

        // Add a single abort log entry (to the first aborted job)
        $jobs = Rank_Booster_DB::get_jobs_by_session( $session_id );
        foreach ( $jobs as $job ) {
            if ( $job->status === 'aborted' ) {
                Rank_Booster_DB::add_log_entry( $job->id, '⛔ Processing aborted by user (' . $affected . ' job(s) cancelled).' );
                break;
            }
        }

        wp_send_json_success( array(
            'message'  => "Aborted. $affected job(s) cancelled.",
            'affected' => $affected,
        ) );
    }

    /* ═══════════════════════════════════════════════
       5. Check for active session (returning user)
       ═══════════════════════════════════════════════ */

    public static function ajax_get_active_session() {
        self::verify();

        $active = Rank_Booster_DB::get_active_session();
        if ( $active ) {
            wp_send_json_success( array(
                'has_active'    => true,
                'session_id'    => $active->session_id,
                'total_jobs'    => (int) $active->total_jobs,
                'active_jobs'   => (int) $active->active_jobs,
                'completed_jobs' => (int) $active->completed_jobs,
            ) );
            return;
        }

        // Check for recently completed session
        $completed = Rank_Booster_DB::get_latest_completed_session();
        if ( $completed ) {
            wp_send_json_success( array(
                'has_active'      => false,
                'has_completed'   => true,
                'session_id'      => $completed->session_id,
                'total_jobs'      => (int) $completed->total_jobs,
                'completed_jobs'  => (int) $completed->completed_jobs,
                'pushed_live_jobs' => (int) $completed->pushed_live_jobs,
            ) );
            return;
        }

        wp_send_json_success( array(
            'has_active'    => false,
            'has_completed' => false,
        ) );
    }

    /* ═══════════════════════════════════════════════
       6. Get preview URL
       ═══════════════════════════════════════════════ */

    public static function ajax_get_preview_url() {
        self::verify();

        $job_id = isset( $_POST['job_id'] ) ? absint( $_POST['job_id'] ) : 0;
        if ( ! $job_id ) {
            wp_send_json_error( array( 'message' => 'No job ID.' ) );
        }

        $job = Rank_Booster_DB::get_job( $job_id );
        if ( ! $job || empty( $job->draft_content ) ) {
            wp_send_json_error( array( 'message' => 'No draft available.' ) );
        }

        if ( isset( $job->object_type ) && $job->object_type === 'term' ) {
            wp_send_json_error( array( 'message' => 'Preview is not available for category pages. Use Push Live to apply changes.' ) );
        }

        $url = Rank_Booster_Preview::generate_preview_url( $job );
        wp_send_json_success( array( 'preview_url' => $url ) );
    }

    /* ═══════════════════════════════════════════════
       7. Push live
       ═══════════════════════════════════════════════ */

    public static function ajax_push_live() {
        self::verify();

        $job_id = isset( $_POST['job_id'] ) ? absint( $_POST['job_id'] ) : 0;
        $result = Rank_Ms_Chat_Services::booster_push_live( $job_id );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
        }

        wp_send_json_success( $result );
    }

    /**
     * Push booster draft live (shared by AJAX and agent REST).
     *
     * @param int $job_id Booster job ID.
     * @return array|WP_Error
     */
    public static function push_live_job( $job_id ) {
        if ( ! $job_id ) {
            return new WP_Error( 'missing_job', 'No job ID.', array( 'status' => 400 ) );
        }

        $result = Rank_Booster_Preview::apply_draft( $job_id );

        if ( is_wp_error( $result ) ) {
            return $result;
        }

        return $result;
    }

    /* ═══════════════════════════════════════════════
       7b. Edit draft — open original post editor with AI injection
       ═══════════════════════════════════════════════ */

    public static function ajax_edit_draft() {
        self::verify();

        $job_id = isset( $_POST['job_id'] ) ? absint( $_POST['job_id'] ) : 0;
        if ( ! $job_id ) {
            wp_send_json_error( array( 'message' => 'No job ID.' ) );
        }

        $job = Rank_Booster_DB::get_job( $job_id );

        if ( isset( $job->object_type ) && $job->object_type === 'term' ) {
            wp_send_json_error( array( 'message' => 'Inline editing is not available for category pages. Use Push Live to apply changes.' ) );
        }

        if ( ! $job || empty( $job->draft_content ) ) {
            wp_send_json_error( array( 'message' => 'No draft available.' ) );
        }

        $post_id = (int) $job->post_id;
        $post    = get_post( $post_id );
        if ( ! $post ) {
            wp_send_json_error( array( 'message' => 'Post not found.' ) );
        }

        // Runtime fallback: re-detect editor if the stored value is empty
        // (covers jobs created before Divi support was added).
        $editor_type = $job->editor_type;
        if ( empty( $editor_type ) ) {
            $editor_type = Rank_Booster::detect_editor( $post_id );
        }

        // ── Debug logging for editor routing (only when debug mode is on) ──
        if ( get_option( 'rank_debug_enabled', false ) ) {
            $divi_meta = get_post_meta( $post_id, '_et_pb_use_builder', true );
            Rank_Processor::add_debug_log( '[Rank Booster] edit_draft routing: job_id=' . $job_id . ' stored=' . ( $job->editor_type ?: 'NULL' ) . ' resolved=' . $editor_type . ' _et_pb_use_builder=' . ( $divi_meta ?: 'empty' ) );
            Rank_Booster_DB::add_log_entry( $job_id, '🔍 Editor routing: stored=' . ( $job->editor_type ?: 'NULL' ) . ', resolved=' . $editor_type );
        }

        // ── Elementor: store draft data for the editor to pick up ──
        // Live _elementor_data is NOT touched. Instead we store the modified
        // data in a custom meta key. The Elementor editor's load/data filter
        // in Rank_Booster_Elementor injects it once when the editor opens.
        // The user must then Save in Elementor to make it live.
        if ( $editor_type === 'elementor' ) {
            $draft_json = Rank_Booster_Elementor::build_preview_data(
                $post_id,
                $job->draft_content,
                ! empty( $job->draft_title ) ? $job->draft_title : ''
            );

            if ( empty( $draft_json ) ) {
                wp_send_json_error( array( 'message' => 'Could not build Elementor draft data.' ) );
            }

            // Store for the editor filter to pick up (one-shot).
            // wp_slash() prevents wp_unslash() inside update_post_meta from stripping
            // backslashes in JSON (e.g. escaped quotes in HTML content).
            update_post_meta( $post_id, Rank_Booster_Elementor::META_DRAFT, wp_slash( $draft_json ) );

            $edit_url = admin_url( 'post.php?post=' . $post_id . '&action=elementor' );
            Rank_Booster_DB::add_log_entry( $job_id, '📝 Opened Elementor editor to review AI draft (not yet live).' );

            wp_send_json_success( array(
                'edit_url' => $edit_url,
                'post_id'  => $post_id,
                'branch'   => 'elementor',
            ) );
        }

        // ── Breakdance: no reliable in-builder draft review. Breakdance loads
        // its tree via its own REST/JS bootstrap, not a plain meta read, so
        // injecting a draft into the builder is unreliable. Use Preview +
        // Push Live (both write the tree directly) instead. ──
        if ( $editor_type === 'breakdance' ) {
            wp_send_json_error( array(
                'message' => 'This page uses Breakdance. Use Preview to review the AI draft, then Push Live to apply it.',
            ) );
        }

        // ── WPBakery: store draft post_content for the frontend editor ──
        // Content lives in post_content (shortcodes), so we store the draft and
        // swap it in when the WPBakery frontend editor loads.
        if ( $editor_type === 'wpbakery' ) {
            $draft_content = $job->draft_content;
            if ( ! empty( $draft_content ) && ! empty( $job->draft_title ) ) {
                $draft_content = Rank_Booster_Preview::inject_h1_into_content( $draft_content, $job->draft_title );
            }
            update_post_meta( $post_id, Rank_Booster_WPBakery::META_DRAFT, $draft_content );

            $edit_url = add_query_arg( 'vc_editable', 'true', get_permalink( $post_id ) );
            Rank_Booster_DB::add_log_entry( $job_id, '📝 Opened WPBakery editor to review AI draft (not yet live).' );

            wp_send_json_success( array(
                'edit_url' => $edit_url,
                'post_id'  => $post_id,
                'branch'   => 'wpbakery',
            ) );
        }

        // ── Custom fields / ACF: no builder editor to open. The draft is
        // applied directly to the fields via Push Live. ──
        if ( $editor_type === 'customfields' ) {
            wp_send_json_error( array(
                'message' => 'This page stores its content in custom fields. Use Preview / Push Live to apply the AI draft to those fields.',
            ) );
        }

        // ── Divi: store draft content for the VB to pick up ──
        // Live post_content is NOT touched. Instead we store the draft in a
        // custom meta key. The Divi VB's the_post hook in Rank_Booster_Divi
        // injects it once when the builder loads. The user must then Save
        // in Divi VB to make it live.
        if ( $editor_type === 'divi' ) {
            $draft_content = $job->draft_content;
            if ( ! empty( $draft_content ) && ! empty( $job->draft_title ) ) {
                $draft_content = Rank_Booster_Preview::inject_h1_into_content( $draft_content, $job->draft_title );
            }
            update_post_meta( $post_id, Rank_Booster_Divi::META_DRAFT, $draft_content );

            if ( get_option( 'rank_debug_enabled', false ) ) {
                $verify     = get_post_meta( $post_id, Rank_Booster_Divi::META_DRAFT, true );
                $verify_msg = 'Stored Divi draft meta for post ' . $post_id . ' (' . strlen( $draft_content ) . ' chars). Read back: ' . ( ! empty( $verify ) ? strlen( $verify ) . ' chars OK' : 'EMPTY — FAILED' );
                Rank_Processor::add_debug_log( '[Rank Booster] ' . $verify_msg );
                Rank_Booster_DB::add_log_entry( $job_id, '💾 ' . $verify_msg );
            }

            $edit_url = add_query_arg(
                array( 'et_fb' => '1', 'PageSpeed' => 'off' ),
                get_permalink( $post_id )
            );
            Rank_Booster_DB::add_log_entry( $job_id, '📝 Opened Divi Visual Builder to review AI draft (not yet live).' );

            wp_send_json_success( array(
                'edit_url' => $edit_url,
                'post_id'  => $post_id,
                'branch'   => 'divi',
            ) );
        }

        // ── Classic / Gutenberg: inject via JS on the WP post editor ──
        // The enqueue_editor_inject() hook will detect the param and inject AI content
        // into the editor state — nothing is saved until the user clicks Update.
        $edit_url = add_query_arg( 'rank_booster_inject', $job_id, get_edit_post_link( $post_id, 'raw' ) );

        // Set pending flag so save_post hook can auto-mark as pushed_live (same pattern as Divi/Elementor).
        update_post_meta( $post_id, '_rank_booster_classic_draft_pending', '1' );

        Rank_Booster_DB::add_log_entry( $job_id, '📝 Opened AI draft in editor for review.' );

        wp_send_json_success( array(
            'edit_url' => $edit_url,
            'post_id'  => $post_id,
            'branch'   => 'classic_gutenberg',
        ) );
    }

    /* ═══════════════════════════════════════════════
       8. Focus keywords for a URL
       ═══════════════════════════════════════════════ */

    public static function ajax_get_focus_keywords() {
        self::verify();

        $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
        if ( empty( $url ) ) {
            wp_send_json_error( array( 'message' => 'No URL provided.' ) );
        }

        $keywords = Rank_Booster::get_focus_keywords_for_url( $url );
        wp_send_json_success( array( 'keywords' => $keywords ) );
    }

    /* ═══════════════════════════════════════════════
       8b. Bulk focus keywords — one API call for all URLs
       ═══════════════════════════════════════════════ */

    public static function ajax_get_bulk_focus_keywords() {
        self::verify();

        $urls_json = isset( $_POST['urls'] ) ? wp_unslash( $_POST['urls'] ) : '';
        $urls      = json_decode( $urls_json, true );

        if ( empty( $urls ) || ! is_array( $urls ) ) {
            wp_send_json_error( array( 'message' => 'No URLs provided.' ) );
        }

        $urls = array_map( 'esc_url_raw', $urls );
        $map  = Rank_Booster::get_bulk_focus_keywords( $urls );

        wp_send_json_success( array( 'keywords_map' => $map ) );
    }

    /* ═══════════════════════════════════════════════
       9. Load a saved session
       ═══════════════════════════════════════════════ */

    public static function ajax_get_session() {
        self::verify();

        $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
        if ( empty( $session_id ) ) {
            wp_send_json_error( array( 'message' => 'No session ID.' ) );
        }

        $jobs = Rank_Booster_DB::get_jobs_by_session( $session_id );
        if ( ! $jobs ) {
            wp_send_json_error( array( 'message' => 'Session not found.' ) );
        }

        $job_data = array();
        foreach ( $jobs as $job ) {
            $job_data[] = self::format_job_for_response( $job );
        }

        wp_send_json_success( array(
            'session_id' => $session_id,
            'jobs'       => $job_data,
        ) );
    }

    /* ═══════════════════════════════════════════════
       10. Optimised pages history (sub-tab)
       ═══════════════════════════════════════════════ */

    public static function ajax_get_optimized_pages() {
        self::verify();

        $page     = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
        $per_page = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20;

        $result = Rank_Booster_DB::get_optimized_pages( $page, $per_page );

        $rows = array();
        foreach ( $result['data'] as $job ) {
            $rows[] = self::format_job_for_response( $job );
        }

        wp_send_json_success( array(
            'rows'        => $rows,
            'total'       => $result['total'],
            'page'        => $page,
            'per_page'    => $per_page,
            'total_pages' => $result['total'] > 0 ? ceil( $result['total'] / $per_page ) : 0,
        ) );
    }

    /* ───────── Format helper ───────── */

    private static function format_job_for_response( $job ) {
        // Auto-detect if user manually updated the post after optimization
        $status = self::maybe_auto_mark_pushed_live( $job );

        return array(
            'id'             => (int) $job->id,
            'session_id'     => $job->session_id,
            'url'            => $job->url,
            'post_id'        => (int) $job->post_id,
            'object_type'    => isset( $job->object_type ) ? $job->object_type : 'post',
            'editor_type'    => $job->editor_type,
            'status'         => $status,
            'mode'           => $job->mode,
            'selected_checks' => $job->selected_checks ? json_decode( $job->selected_checks, true ) : array(),
            'focus_keywords' => $job->focus_keywords ? json_decode( $job->focus_keywords, true ) : array(),
            'credits_used'   => (int) $job->credits_used,
            'has_draft'      => self::job_has_draft( $job ),
            'summary_html'   => $job->summary_html,
            'custom_prompt'  => $job->custom_prompt ? $job->custom_prompt : '',
            'draft_content'  => self::prepare_draft_content_for_display( $job ),
            'log_entries'    => $job->log_entries ? json_decode( $job->log_entries, true ) : array(),
            'error'          => $job->error_message,
            'pushed_live_at' => $job->pushed_live_at,
            'created_at'     => $job->created_at,
            'updated_at'     => $job->updated_at,
        );
    }

    /**
     * Whether a completed job has reviewable optimized output.
     *
     * Post jobs always carry draft_content. Term jobs may be title/meta only
     * (no draft_content), so we also count an optimized title tag or meta desc.
     *
     * @param object $job Job DB row.
     * @return bool
     */
    private static function job_has_draft( $job ) {
        if ( ! empty( $job->draft_content ) ) {
            return true;
        }
        if ( isset( $job->object_type ) && $job->object_type === 'term' ) {
            return ! empty( $job->draft_title_tag ) || ! empty( $job->draft_meta );
        }
        return false;
    }

    /**
     * Prepare draft_content for display in the admin content modal.
     *
     * For editors where the visual H1 lives inside post_content (Divi, WPBakery,
     * Gutenberg, Classic), the API returns the improved H1 in draft_title but does
     * NOT update it inside post_content_html. This helper injects the improved H1
     * into the content so the modal shows the correct version.
     *
     * @param object $job Job DB row.
     * @return string
     */
    private static function prepare_draft_content_for_display( $job ) {
        if ( empty( $job->draft_content ) || empty( $job->draft_title ) ) {
            return $job->draft_content;
        }
        $non_slot_editors = array( 'divi', 'wpbakery', 'gutenberg', 'classic' );
        if ( ! in_array( $job->editor_type, $non_slot_editors, true ) ) {
            return $job->draft_content;
        }
        return Rank_Booster_Preview::inject_h1_into_content( $job->draft_content, $job->draft_title );
    }

    /**
     * Cache of job IDs already checked this request to avoid redundant get_post() calls.
     * Maps job_id => effective status.
     */
    private static $auto_mark_cache = array();

    /**
     * If a completed job's post was modified after the optimization finished,
     * auto-mark the job as pushed_live (the user likely edited the draft and saved).
     *
     * Uses a 2-minute buffer to avoid false positives from the optimization itself.
     * Skips jobs completed less than 2 minutes ago (impossible to trigger yet).
     * Caches results per-request to avoid duplicate get_post() calls during polling.
     *
     * @param object $job DB row.
     * @return string The effective status.
     */
    private static function maybe_auto_mark_pushed_live( $job ) {
        if ( $job->status !== 'completed' || empty( $job->post_id ) ) {
            return $job->status;
        }

        // Terms have no post_modified timestamp, and their ID could collide with
        // a real post ID — never auto-detect them as pushed live.
        if ( isset( $job->object_type ) && $job->object_type === 'term' ) {
            return $job->status;
        }

        // Return cached result if already checked this request
        if ( isset( self::$auto_mark_cache[ $job->id ] ) ) {
            return self::$auto_mark_cache[ $job->id ];
        }

        // Skip if job finished less than 2 minutes ago — can't be auto-detected yet.
        // current_time('timestamp') (WP local epoch) matches how updated_at is
        // stored (current_time('mysql') = local time); time() (UTC) would skew this.
        $job_finished = strtotime( $job->updated_at );
        if ( current_time( 'timestamp' ) - $job_finished < 120 ) {
            self::$auto_mark_cache[ $job->id ] = $job->status;
            return $job->status;
        }

        $post = get_post( (int) $job->post_id );
        if ( ! $post ) {
            self::$auto_mark_cache[ $job->id ] = $job->status;
            return $job->status;
        }

        $post_modified = strtotime( $post->post_modified );

        // If the post was modified more than 2 minutes after optimization completed
        if ( $post_modified > $job_finished + 120 ) {
            Rank_Booster_DB::update_job( $job->id, array(
                'status'         => 'pushed_live',
                'pushed_live_at' => $post->post_modified,
            ) );
            Rank_Booster_DB::add_log_entry( $job->id, '🚀 Auto-detected as live: ' . $job->url );
            self::$auto_mark_cache[ $job->id ] = 'pushed_live';
            return 'pushed_live';
        }

        self::$auto_mark_cache[ $job->id ] = $job->status;
        return $job->status;
    }

    /* ───────── Stuck-job recovery ───────── */

    /**
     * Recover a stuck 'processing' job.
     * Simply marks it as failed and schedules the next queued job so the queue doesn't stall.
     *
     * @param object $job DB row.
     * @return string 'failed'
     */
    private static function recover_stuck_job( $job ) {
        $stuck_min = round( ( current_time( 'timestamp' ) - strtotime( $job->updated_at ) ) / 60, 1 );
        error_log( 'Rank Booster: job_id=' . $job->id . ' stuck for ' . $stuck_min . ' min — marking failed' );

        Rank_Booster_DB::update_job( $job->id, array(
            'status'        => 'failed',
            'error_message' => 'Processing timed out after ' . $stuck_min . ' minutes. Please re-run this page.',
        ) );
        Rank_Booster_DB::add_log_entry( $job->id,
            '✗ Timed out after ' . $stuck_min . ' min — marked as failed.'
        );

        // Unblock the queue for remaining jobs
        Rank_Booster_Processor::schedule_session( $job->session_id );

        return 'failed';
    }

    /* ───────── Credits helper ───────── */

    private static function get_credits_info() {
        $credits_data = rank_get_ai_credits();
        $info = array( 'remaining' => 0, 'total' => 0 );
        if ( ! is_wp_error( $credits_data ) && isset( $credits_data['limit'], $credits_data['usage'] ) ) {
            $info['total']     = intval( $credits_data['limit'] );
            $info['remaining'] = max( 0, intval( $credits_data['limit'] ) - intval( $credits_data['usage'] ) );
        }
        return $info;
    }

    /* ═══════════════════════════════════════════════
       Get AI credits — for initial page load
       ═══════════════════════════════════════════════ */

    public static function ajax_get_credits() {
        self::verify();
        wp_send_json_success( array( 'credits' => self::get_credits_info() ) );
    }

    /* ═══════════════════════════════════════════════
       Export all optimized pages as CSV data
       ═══════════════════════════════════════════════ */

    public static function ajax_export_csv() {
        self::verify();

        $rows = Rank_Booster_DB::get_all_optimized_pages();

        $csv = array();
        foreach ( $rows as $job ) {
            $status = self::maybe_auto_mark_pushed_live( $job );
            $checks = $job->selected_checks ? json_decode( $job->selected_checks, true ) : array();

            $csv[] = array(
                'url'        => $job->url,
                'optimized'  => $job->updated_at,
                'mode'       => $job->mode,
                'checks'     => $checks ? implode( ', ', $checks ) : 'Automatic',
                'status'     => $status === 'pushed_live' ? 'Live' : $status,
                'created_at' => $job->created_at,
            );
        }

        wp_send_json_success( array( 'rows' => $csv ) );
    }
}
