<?php
/**
 * Rank Booster Processor
 *
 * Server-side processing via Action Scheduler.
 * Two hooks:
 *   rank_booster_process_page — starts optimization for one job
 *   rank_booster_poll_job     — polls API job status (self-rescheduling)
 */

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

class Rank_Booster_Processor {

    public static function init() {
        add_action( 'rank_booster_process_page', array( __CLASS__, 'process_page' ), 10, 1 );
        add_action( 'rank_booster_poll_job', array( __CLASS__, 'poll_job' ), 10, 1 );
        add_action( 'rank_booster_watchdog', array( __CLASS__, 'watchdog' ) );

        // Give Action Scheduler enough time for the API call (120s timeout + overhead).
        // Default AS timeout is ~30s which kills process_page mid-API-call.
        add_filter( 'action_scheduler_queue_runner_time_limit', function( $limit ) {
            return max( $limit, 180 );
        } );
    }

    /* ═══════════════════════════════════════════════
       Background watchdog scheduling (session-scoped)
       ═══════════════════════════════════════════════ */

    /**
     * Start the recurring background watchdog if it isn't already running.
     * Called when a session has active jobs so stuck jobs are recovered even
     * when no admin tab is open. Runs every 5 min via Action Scheduler.
     */
    private static function start_watchdog() {
        if ( function_exists( 'as_schedule_recurring_action' )
            && function_exists( 'as_next_scheduled_action' )
            && false === as_next_scheduled_action( 'rank_booster_watchdog' )
        ) {
            as_schedule_recurring_action( time() + 300, 300, 'rank_booster_watchdog', array(), 'rank_booster' );
        }
    }

    /**
     * Stop the watchdog once no session has active (queued/processing) jobs.
     * Public so the abort handler can also trigger cleanup.
     */
    public static function maybe_stop_watchdog() {
        if ( ! class_exists( 'Rank_Booster_DB' ) || Rank_Booster_DB::get_active_session() ) {
            return; // A session is still active — keep the watchdog running.
        }
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( 'rank_booster_watchdog' );
        }
    }

    /* ═══════════════════════════════════════════════
       Background watchdog (Action Scheduler recurring)
       ═══════════════════════════════════════════════ */

    /**
     * Scan for jobs stuck at 'processing' with no activity for longer than the
     * stuck-job timeout and recover them (mark failed + unblock the queue).
     *
     * This mirrors the on-page recovery in Rank_Booster_Ajax but runs in the
     * background so a closed admin tab no longer means a stranded job sits
     * forever. Idempotent and cheap: 'processing' rows are few.
     */
    public static function watchdog() {
        if ( ! class_exists( 'Rank_Booster_DB' ) ) {
            return;
        }

        global $wpdb;
        $table = Rank_Booster_DB::table_name();
        $jobs  = $wpdb->get_results( "SELECT id, updated_at FROM $table WHERE status = 'processing'" );
        if ( empty( $jobs ) ) {
            return;
        }

        // Same threshold the on-page recovery uses (per job, since last activity).
        $threshold = class_exists( 'Rank_Booster_Ajax' ) ? Rank_Booster_Ajax::STUCK_JOB_TIMEOUT : 420;
        $now       = current_time( 'timestamp' );

        foreach ( $jobs as $job ) {
            $age = $now - strtotime( $job->updated_at );
            if ( $age > $threshold ) {
                $mins = round( $age / 60, 1 );
                error_log( 'Rank Booster watchdog: job_id=' . $job->id . ' stuck for ' . $mins . ' min — marking failed' );
                self::fail_job(
                    $job->id,
                    'Processing timed out after ' . $mins . ' minutes (background watchdog). Please re-run this page.'
                );
            }
        }
    }

    /* ═══════════════════════════════════════════════
       Schedule all jobs in a session
       ═══════════════════════════════════════════════ */

    /**
     * Schedule the first queued job in a session.
     * Jobs run sequentially — each job schedules the next on completion.
     *
     * @param string $session_id
     */
    public static function schedule_session( $session_id ) {
        $jobs  = Rank_Booster_DB::get_jobs_by_session( $session_id );
        $group = 'rank_booster_' . $session_id;

        foreach ( $jobs as $job ) {
            if ( $job->status === 'queued' ) {
                as_schedule_single_action(
                    time() + 10,
                    'rank_booster_process_page',
                    array( (int) $job->id ),
                    $group
                );
                // Ensure the background watchdog is running while jobs are pending.
                self::start_watchdog();
                return;
            }
        }

        // No queued jobs left — if nothing is still active, stop the watchdog.
        self::maybe_stop_watchdog();
    }

    /* ═══════════════════════════════════════════════
       Process a single page (Action Scheduler callback)
       ═══════════════════════════════════════════════ */

    public static function process_page( $job_id ) {
        // Extend PHP time limit to survive the 120s API timeout + overhead
        @set_time_limit( 300 );

        $debug = get_option( 'rank_debug_enabled', false );

        if ( $debug ) {
            error_log( 'Rank Booster process_page: job_id=' . $job_id . ' max_execution_time=' . ini_get( 'max_execution_time' ) . ' memory_limit=' . ini_get( 'memory_limit' ) );
        }

        // Register shutdown handler to detect if the process is killed mid-execution
        register_shutdown_function( function() use ( $job_id ) {
            $error = error_get_last();
            if ( $error && in_array( $error['type'], array( E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR ), true ) ) {
                error_log( 'Rank Booster process_page: FATAL SHUTDOWN for job_id=' . $job_id . ' error=' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line'] );
                // Try to mark the job as failed so the queue doesn't stall
                if ( class_exists( 'Rank_Booster_DB' ) ) {
                    Rank_Booster_DB::update_job( $job_id, array(
                        'status'        => 'failed',
                        'error_message' => 'PHP fatal error: ' . $error['message'],
                    ) );
                    Rank_Booster_DB::add_log_entry( $job_id, '✗ Process killed: ' . $error['message'] );
                }
            }
        } );

        $job = Rank_Booster_DB::get_job( $job_id );
        if ( ! $job ) {
            error_log( 'Rank Booster process_page: job_id=' . $job_id . ' not found' );
            return;
        }

        // Skip if not queued (e.g. already aborted)
        if ( $job->status !== 'queued' ) {
            if ( $debug ) {
                error_log( 'Rank Booster process_page: job_id=' . $job_id . ' skipped (status=' . $job->status . ')' );
            }
            return;
        }

        // Mark as processing
        Rank_Booster_DB::update_job( $job_id, array( 'status' => 'processing' ) );
        Rank_Booster_DB::add_log_entry( $job_id, 'Starting optimization for ' . $job->url . '…' );
        if ( $debug ) {
            error_log( 'Rank Booster process_page: job_id=' . $job_id . ' url=' . $job->url . ' mode=' . $job->mode );
        }

        // Timing instrumentation: pinpoints which step blocks when a worker is
        // reaped mid-run. Debug-gated, written to the activity log so it's visible
        // in the UI without server-log access.
        $t0 = microtime( true );

        // Gather post data
        $focus_kw_arr = $job->focus_keywords ? json_decode( $job->focus_keywords, true ) : array();
        $focus_kw_str = implode( ', ', $focus_kw_arr );

        // Pass $job->url so the gather uses the original submitted URL for url_post.
        // On WPML/Polylang sites get_permalink() is language-context-dependent and can return
        // the wrong language URL; the job URL is always the exact URL the user submitted.
        // For terms, post_id holds the term ID and we gather term data instead.
        if ( $job->object_type === 'term' ) {
            $post_data = Rank_Booster::get_term_data_for_api( (int) $job->post_id, $focus_kw_str, $job->url );
        } else {
            $post_data = Rank_Booster::get_post_data_for_api( (int) $job->post_id, $focus_kw_str, $job->url, $job->editor_type );
        }
        if ( is_wp_error( $post_data ) ) {
            self::fail_job( $job_id, $post_data->get_error_message() );
            return;
        }
        if ( $debug ) {
            Rank_Booster_DB::add_log_entry( $job_id, '⏱ Gathered post data in ' . round( microtime( true ) - $t0, 1 ) . 's' );
        }

        // Visibility check: confirm the content we're about to optimize is
        // actually rendered on the live page. If it isn't, the theme is likely
        // rendering from a builder / custom field we don't reach — warn the user
        // instead of silently producing an invisible change.
        // NOTE: this makes a loopback HTTP request to the site's own frontend,
        // which can deadlock/hang on hosts with few PHP-FPM workers or a raised
        // http_request_timeout — a prime suspect for silent >API-time blocking.
        $t_probe = microtime( true );
        self::check_visibility( $job, $post_data );
        if ( $debug ) {
            Rank_Booster_DB::add_log_entry( $job_id, '⏱ Visibility probe finished in ' . round( microtime( true ) - $t_probe, 1 ) . 's' );
        }

        if ( $job->mode === 'automatic' ) {
            self::handle_automatic( $job, $post_data );
        } else {
            self::handle_manual( $job, $post_data );
        }
    }

    /**
     * Probe the live page once and warn if the source content we're optimizing
     * has low coverage on the rendered frontend. Non-blocking: only logs.
     *
     * @param object $job
     * @param array  $post_data  Gathered API payload (has url_post + content).
     */
    private static function check_visibility( $job, $post_data ) {
        // Terms render from archive templates we can't reliably probe — skip.
        if ( isset( $job->object_type ) && $job->object_type === 'term' ) {
            return;
        }
        $url     = isset( $post_data['url_post'] ) ? $post_data['url_post'] : $job->url;
        $content = isset( $post_data['post_content_html'] ) ? $post_data['post_content_html'] : '';
        if ( empty( $url ) || trim( wp_strip_all_tags( $content ) ) === '' ) {
            return;
        }
        if ( ! class_exists( 'Rank_Booster_Render_Probe' ) ) {
            return;
        }

        $result = Rank_Booster_Render_Probe::coverage( $url, $content );

        if ( ! empty( $result['error'] ) ) {
            // Probe failed (timeout, auth wall, etc.) — note it but don't block.
            Rank_Booster_DB::add_log_entry( $job->id, 'ℹ️ Could not verify on-page visibility (' . $result['error'] . ').' );
            return;
        }
        if ( $result['total'] > 0 && $result['score'] < Rank_Booster_Render_Probe::COVERAGE_OK ) {
            $pct = round( $result['score'] * 100 );
            Rank_Booster_DB::add_log_entry(
                $job->id,
                '⚠️ Only ' . $pct . '% of the content being optimized was found on the live page. '
                . 'The theme may render the visible text from a page builder or custom field, so changes might not appear on the frontend.'
            );
        }
    }

    /* ───────── Automatic mode: orchestrate=true ───────── */

    private static function handle_automatic( $job, $post_data ) {
        Rank_Booster_DB::add_log_entry( $job->id, 'Analyzing page content (automatic mode)…' );

        $debug   = get_option( 'rank_debug_enabled', false );
        $t_start = microtime( true );

        if ( $debug ) {
            error_log( 'Rank Booster handle_automatic: job_id=' . $job->id . ' calling analyze API at ' . gmdate( 'Y-m-d H:i:s' ) );
        }

        $result  = Rank_Booster::call_analyze( $post_data, true );
        $elapsed = round( microtime( true ) - $t_start, 2 );

        if ( is_wp_error( $result ) ) {
            if ( $debug ) {
                error_log( 'Rank Booster handle_automatic: job_id=' . $job->id . ' analyze failed after ' . $elapsed . 's — ' . $result->get_error_message() );
            }
            self::fail_job( $job->id, 'Analyze API error: ' . $result->get_error_message() );
            return;
        }

        if ( $debug ) {
            error_log( 'Rank Booster handle_automatic: job_id=' . $job->id . ' analyze response keys=' . implode( ',', array_keys( $result ) ) . ' elapsed=' . $elapsed . 's' );
        }

        if ( empty( $result['jobId'] ) ) {
            if ( $debug ) {
                error_log( 'Rank Booster handle_automatic: job_id=' . $job->id . ' no jobId returned — no improvements needed' );
            }
            Rank_Booster_DB::add_log_entry( $job->id, 'Analysis complete. No improvements needed.' );
            self::store_analysis_only( $job->id, $result );
            return;
        }

        // Store API job ID and start polling
        if ( $debug ) {
            error_log( 'Rank Booster handle_automatic: job_id=' . $job->id . ' api_job_id=' . $result['jobId'] );
        }
        Rank_Booster_DB::update_job( $job->id, array( 'api_job_id' => $result['jobId'] ) );

        if ( ! empty( $result['analysisData'] ) ) {
            Rank_Booster_DB::update_job( $job->id, array(
                'analysis_data' => wp_json_encode( $result['analysisData'] ),
            ) );
        }

        $checks = isset( $result['checksToRun'] ) ? $result['checksToRun'] : array();
        Rank_Booster_DB::add_log_entry(
            $job->id,
            'Analysis complete. Running ' . count( $checks ) . ' improvement(s): ' . implode( ', ', $checks )
        );

        // Schedule first poll in 5 seconds
        $group = 'rank_booster_' . $job->session_id;
        as_schedule_single_action(
            time() + 5,
            'rank_booster_poll_job',
            array( (int) $job->id ),
            $group
        );
    }

    /* ───────── Manual mode: call individual endpoints ───────── */

    private static function handle_manual( $job, $post_data ) {
        $checks = $job->selected_checks ? json_decode( $job->selected_checks, true ) : array();
        if ( empty( $checks ) ) {
            self::fail_job( $job->id, 'No checks selected.' );
            return;
        }

        Rank_Booster_DB::add_log_entry( $job->id, 'Running ' . count( $checks ) . ' selected improvement(s)…' );

        $progress = array(
            'checks'       => array_values( $checks ),
            'index'        => 0,
            'original_data'=> $post_data,
            'current_data' => $post_data,
            'all_actions'  => array(),
        );

        Rank_Booster_DB::update_job( $job->id, array(
            'manual_progress' => wp_json_encode( $progress ),
        ) );

        self::start_next_manual_check( $job, $progress );
    }

    private static function start_next_manual_check( $job, $progress ) {
        $checks = isset( $progress['checks'] ) && is_array( $progress['checks'] ) ? $progress['checks'] : array();
        $index  = isset( $progress['index'] ) ? (int) $progress['index'] : 0;

        if ( $index >= count( $checks ) ) {
            self::finalize_manual_job( $job, $progress );
            return;
        }

        $check      = $checks[ $index ];
        $categories = Rank_Booster::get_check_categories();
        $label      = isset( $categories[ $check ] ) ? $categories[ $check ] : $check;
        $step_label = '(Step ' . ( $index + 1 ) . ' of ' . count( $checks ) . ')';
        $current_data = isset( $progress['current_data'] ) && is_array( $progress['current_data'] )
            ? $progress['current_data']
            : array();

        Rank_Booster_DB::add_log_entry( $job->id, "⟳ Running: $label $step_label" );

        if ( $check === 'custom_prompt' ) {
            $prompt = trim( (string) ( $job->custom_prompt ?? '' ) );
            if ( $prompt === '' ) {
                Rank_Booster_DB::add_log_entry( $job->id, "✗ $label failed: Custom instructions are required $step_label" );
                $progress['all_actions'][] = '<li><strong>' . esc_html( $label ) . ':</strong> Failed - Custom instructions are required</li>';
                $progress['index'] = $index + 1;
                Rank_Booster_DB::update_job( $job->id, array(
                    'manual_progress' => wp_json_encode( $progress ),
                    'api_job_id'      => null,
                ) );
                self::start_next_manual_check( $job, $progress );
                return;
            }
            if ( strlen( $prompt ) > 2000 ) {
                Rank_Booster_DB::add_log_entry( $job->id, "✗ $label failed: Custom instructions exceed 2000 characters $step_label" );
                $progress['all_actions'][] = '<li><strong>' . esc_html( $label ) . ':</strong> Failed - Custom instructions must be 2000 characters or fewer</li>';
                $progress['index'] = $index + 1;
                Rank_Booster_DB::update_job( $job->id, array(
                    'manual_progress' => wp_json_encode( $progress ),
                    'api_job_id'      => null,
                ) );
                self::start_next_manual_check( $job, $progress );
                return;
            }
            $current_data['custom_prompt'] = $prompt;
        }

        $result = Rank_Booster::call_individual_check_async( $check, $current_data, '' );
        if ( is_wp_error( $result ) ) {
            $err_data = $result->get_error_data();
            $debug_info = '';
            if ( is_array( $err_data ) ) {
                $debug_info .= ' HTTP=' . ( $err_data['status'] ?? '?' );
                if ( ! empty( $err_data['body'] ) ) {
                    $debug_info .= ' body=' . wp_json_encode( $err_data['body'] );
                }
            }

            Rank_Processor::add_debug_log( '[Rank Booster] async manual check START failed: check=' . $check . ' error=' . $result->get_error_message() . $debug_info );
            Rank_Booster_DB::add_log_entry( $job->id, "✗ $label failed: " . $result->get_error_message() . $debug_info . " $step_label" );

            $progress['all_actions'][] = '<li><strong>' . esc_html( $label ) . ':</strong> Failed - ' . esc_html( $result->get_error_message() ) . '</li>';
            $progress['index'] = $index + 1;
            Rank_Booster_DB::update_job( $job->id, array(
                'manual_progress' => wp_json_encode( $progress ),
                'api_job_id'      => null,
            ) );

            self::start_next_manual_check( $job, $progress );
            return;
        }

        if ( empty( $result['jobId'] ) ) {
            Rank_Booster_DB::add_log_entry( $job->id, "✗ $label failed: API did not return a jobId $step_label" );
            $progress['all_actions'][] = '<li><strong>' . esc_html( $label ) . ':</strong> Failed - API did not return a jobId</li>';
            $progress['index'] = $index + 1;
            Rank_Booster_DB::update_job( $job->id, array(
                'manual_progress' => wp_json_encode( $progress ),
                'api_job_id'      => null,
            ) );
            self::start_next_manual_check( $job, $progress );
            return;
        }

        Rank_Booster_DB::update_job( $job->id, array(
            'api_job_id'      => $result['jobId'],
            'manual_progress' => wp_json_encode( $progress ),
        ) );

        self::reschedule_poll( $job );
    }

    /* ═══════════════════════════════════════════════
       Poll API job status (self-rescheduling)
       ═══════════════════════════════════════════════ */

    public static function poll_job( $job_id ) {
        // Extend time limit — poll_job makes an API call that can take 30s
        @set_time_limit( 120 );

        $debug = get_option( 'rank_debug_enabled', false );

        $job = Rank_Booster_DB::get_job( $job_id );
        if ( ! $job || ! $job->api_job_id ) {
            error_log( 'Rank Booster poll_job: job_id=' . $job_id . ' not found or no api_job_id' );
            return;
        }

        // Skip if already completed or aborted
        if ( $job->status !== 'processing' ) {
            if ( $debug ) {
                error_log( 'Rank Booster poll_job: job_id=' . $job_id . ' skipped (status=' . $job->status . ')' );
            }
            return;
        }

        if ( $debug ) {
            error_log( 'Rank Booster poll_job: polling api_job_id=' . $job->api_job_id . ' for job_id=' . $job_id );
        }

        $status = Rank_Booster::call_job_status( $job->api_job_id );

        if ( is_wp_error( $status ) ) {
            $err_msg   = $status->get_error_message();
            $err_data  = $status->get_error_data();
            $http_code = is_array( $err_data ) && isset( $err_data['status'] ) ? (int) $err_data['status'] : 0;

            error_log( 'Rank Booster poll_job: API error for job_id=' . $job_id . ' HTTP=' . $http_code . ' — ' . $err_msg );

            // 404 or 410 = API purged the job — permanent failure, don't retry
            if ( $http_code === 404 || $http_code === 410 ) {
                self::fail_job( $job_id, 'API job not found (HTTP ' . $http_code . '): ' . $err_msg );
                return;
            }

            Rank_Booster_DB::add_log_entry( $job_id, '⚠ Poll error (HTTP ' . $http_code . '): ' . $err_msg . ' — retrying…' );
            self::reschedule_poll( $job );
            return;
        }

        $job_status = isset( $status['status'] ) ? $status['status'] : 'unknown';

        if ( $debug ) {
            $step_count = ! empty( $status['steps'] ) ? count( $status['steps'] ) : 0;
            error_log( 'Rank Booster poll_job: job_id=' . $job_id . ' api_status=' . $job_status . ' steps=' . $step_count );
            if ( ! empty( $status['steps'] ) ) {
                foreach ( $status['steps'] as $s ) {
                    error_log( 'Rank Booster poll_job: step ' . $s['check'] . ' = ' . $s['status'] . ( ! empty( $s['actionTaken'] ) ? ' (' . $s['actionTaken'] . ')' : '' ) );
                }
            }
        }

        if ( ! empty( $job->manual_progress ) ) {
            self::handle_manual_poll_status( $job, $status, $job_status );
            return;
        }

        if ( ! empty( $status['steps'] ) ) {
            self::log_step_updates( $job_id, $status['steps'] );
        }

        if ( $job_status === 'running' ) {
            self::reschedule_poll( $job );
            return;
        }

        if ( $job_status === 'completed' ) {
            if ( $debug ) {
                error_log( 'Rank Booster poll_job: job_id=' . $job_id . ' completed — storing results' );
            }
            self::store_orchestration_results( $job_id, $status );
            return;
        }

        if ( $job_status === 'failed' ) {
            $error = isset( $status['error'] ) ? $status['error'] : 'Unknown API error';
            error_log( 'Rank Booster poll_job: job_id=' . $job_id . ' failed — ' . $error );
            self::fail_job( $job_id, 'API job failed: ' . $error );
            return;
        }

        // Unknown status — retry
        if ( $debug ) {
            error_log( 'Rank Booster poll_job: job_id=' . $job_id . ' unknown status=' . $job_status . ' — retrying' );
        }
        self::reschedule_poll( $job );
    }

    /* ───────── Helpers ───────── */

    private static function handle_manual_poll_status( $job, $status, $job_status ) {
        $progress = json_decode( $job->manual_progress, true );
        if ( ! is_array( $progress ) ) {
            self::fail_job( $job->id, 'Manual progress state is invalid.');
            return;
        }

        if ( $job_status === 'running' ) {
            self::reschedule_poll( $job );
            return;
        }

        $checks = isset( $progress['checks'] ) && is_array( $progress['checks'] ) ? $progress['checks'] : array();
        $index  = isset( $progress['index'] ) ? (int) $progress['index'] : 0;
        $check  = isset( $checks[ $index ] ) ? $checks[ $index ] : '';
        $categories = Rank_Booster::get_check_categories();
        $label = isset( $categories[ $check ] ) ? $categories[ $check ] : $check;
        $step_label = '(Step ' . ( $index + 1 ) . ' of ' . count( $checks ) . ')';

        if ( $job_status === 'completed' ) {
            $result = isset( $status['result'] ) && is_array( $status['result'] ) ? $status['result'] : array();
            $step = ! empty( $status['steps'][0] ) && is_array( $status['steps'][0] ) ? $status['steps'][0] : array();
            $current_data = isset( $progress['current_data'] ) && is_array( $progress['current_data'] )
                ? $progress['current_data']
                : array();

            if ( ! empty( $result['post_content_html'] ) ) {
                $stripped = self::strip_action_taken( $result['post_content_html'] );
                $current_data['post_content_html'] = $stripped['content'];
                if ( empty( $step['actionTaken'] ) && ! empty( $stripped['action'] ) ) {
                    $step['actionTaken'] = $stripped['action'];
                }
            }
            if ( ! empty( $result['title_post'] ) ) {
                $current_data['title_post'] = $result['title_post'];
            }
            if ( ! empty( $result['meta_post'] ) ) {
                $current_data['meta_post'] = $result['meta_post'];
            }
            if ( ! empty( $result['h1_header'] ) ) {
                $current_data['h1_header'] = $result['h1_header'];
            }

            $action = ! empty( $step['actionTaken'] ) ? $step['actionTaken'] : 'Done';
            $progress['current_data'] = $current_data;
            $progress['all_actions'][] = '<li><strong>' . esc_html( $label ) . ':</strong> ' . esc_html( $action ) . '</li>';
            $progress['index'] = $index + 1;

            Rank_Booster_DB::add_log_entry( $job->id, "✓ $label — $action $step_label" );
            Rank_Booster_DB::update_job( $job->id, array(
                'api_job_id'      => null,
                'manual_progress' => wp_json_encode( $progress ),
            ) );

            $updated_job = Rank_Booster_DB::get_job( $job->id );
            self::start_next_manual_check( $updated_job ?: $job, $progress );
            return;
        }

        if ( $job_status === 'failed' ) {
            $error = isset( $status['error'] ) ? $status['error'] : 'Unknown API error';
            $progress['all_actions'][] = '<li><strong>' . esc_html( $label ) . ':</strong> Failed - ' . esc_html( $error ) . '</li>';
            $progress['index'] = $index + 1;

            Rank_Booster_DB::add_log_entry( $job->id, "✗ $label failed: $error $step_label" );
            Rank_Booster_DB::update_job( $job->id, array(
                'api_job_id'      => null,
                'manual_progress' => wp_json_encode( $progress ),
            ) );

            $updated_job = Rank_Booster_DB::get_job( $job->id );
            self::start_next_manual_check( $updated_job ?: $job, $progress );
            return;
        }

        self::reschedule_poll( $job );
    }

    private static function reschedule_poll( $job ) {
        $group = 'rank_booster_' . $job->session_id;
        as_schedule_single_action(
            time() + 5,
            'rank_booster_poll_job',
            array( (int) $job->id ),
            $group
        );
    }

    private static function finalize_manual_job( $job, $progress ) {
        $checks = isset( $progress['checks'] ) && is_array( $progress['checks'] ) ? $progress['checks'] : array();
        $current_data = isset( $progress['current_data'] ) && is_array( $progress['current_data'] )
            ? $progress['current_data']
            : array();
        $original_data = isset( $progress['original_data'] ) && is_array( $progress['original_data'] )
            ? $progress['original_data']
            : $current_data;
        $all_actions = isset( $progress['all_actions'] ) && is_array( $progress['all_actions'] )
            ? $progress['all_actions']
            : array();

        if ( $job->editor_type === 'gutenberg' ) {
            $valid = self::validate_gutenberg_content( $original_data['post_content_html'] ?? '', $current_data['post_content_html'] ?? '' );
            if ( is_wp_error( $valid ) ) {
                Rank_Booster_DB::add_log_entry( $job->id, '⚠️ ' . $valid->get_error_message() );
            }
        } elseif ( $job->editor_type === 'divi' ) {
            $valid = self::validate_divi_content( $original_data['post_content_html'] ?? '', $current_data['post_content_html'] ?? '' );
            if ( is_wp_error( $valid ) ) {
                Rank_Booster_DB::add_log_entry( $job->id, '⚠️ ' . $valid->get_error_message() );
            }
        } elseif ( $job->editor_type === 'wpbakery' ) {
            $valid = Rank_Booster_WPBakery::validate_content( $original_data['post_content_html'] ?? '', $current_data['post_content_html'] ?? '' );
            if ( is_wp_error( $valid ) ) {
                Rank_Booster_DB::add_log_entry( $job->id, '⚠️ ' . $valid->get_error_message() );
            }
        }

        $update = array(
            'status'          => 'completed',
            'api_job_id'      => null,
            'manual_progress' => null,
            'draft_content'   => $current_data['post_content_html'] ?? '',
            'summary_html'    => '<ul>' . implode( '', $all_actions ) . '</ul>',
        );

        if ( in_array( 'improve_header', $checks, true ) ) {
            $update['draft_title'] = $current_data['h1_header'] ?? '';
            $update['draft_h1']    = $current_data['h1_header'] ?? '';
        }
        if ( in_array( 'improve_title_tag', $checks, true ) ) {
            $update['draft_title_tag'] = $current_data['title_post'] ?? '';
        }
        if ( in_array( 'improve_meta_description', $checks, true ) ) {
            $update['draft_meta'] = $current_data['meta_post'] ?? '';
        }

        Rank_Booster_DB::update_job( $job->id, $update );
        Rank_Booster_DB::add_log_entry( $job->id, '✓ Optimization complete for ' . $job->url );
        self::schedule_session( $job->session_id );
    }

    /**
     * Log step completions. Keeps track of what was already logged
     * by comparing step statuses to avoid duplicate log entries.
     */
    private static function log_step_updates( $job_id, $steps ) {
        $job     = Rank_Booster_DB::get_job( $job_id );
        $entries = $job->log_entries ? json_decode( $job->log_entries, true ) : array();

        // Build map of label → logged status (running, completed, failed)
        $logged = array();
        foreach ( $entries as $e ) {
            if ( preg_match( '/^✓ (.+?) —/u', $e['message'], $m ) ) {
                $logged[ $m[1] ] = 'completed';
            } elseif ( preg_match( '/^✗ (.+?) —/u', $e['message'], $m ) ) {
                $logged[ $m[1] ] = 'failed';
            } elseif ( preg_match( '/^⟳ (.+?) —/u', $e['message'], $m ) ) {
                if ( ! isset( $logged[ $m[1] ] ) ) {
                    $logged[ $m[1] ] = 'running';
                }
            }
        }

        $categories = Rank_Booster::get_check_categories();
        $total = count( $steps );
        foreach ( $steps as $i => $step ) {
            $check  = $step['check'];
            $label  = isset( $categories[ $check ] ) ? $categories[ $check ] : $check;
            $status = $step['status'];
            $prev   = isset( $logged[ $label ] ) ? $logged[ $label ] : null;
            $step_label = '(Step ' . ( $i + 1 ) . ' of ' . $total . ')';

            if ( $status === 'completed' && $prev !== 'completed' ) {
                $action = ! empty( $step['actionTaken'] ) ? $step['actionTaken'] : 'Done';
                Rank_Booster_DB::add_log_entry( $job_id, "✓ $label — $action $step_label" );
            } elseif ( $status === 'running' && ! $prev ) {
                Rank_Booster_DB::add_log_entry( $job_id, "⟳ $label — Processing… $step_label" );
            } elseif ( $status === 'failed' && $prev !== 'failed' ) {
                Rank_Booster_DB::add_log_entry( $job_id, "✗ $label — Failed $step_label" );
            }
        }
    }

    private static function store_orchestration_results( $job_id, $status ) {
        $result = isset( $status['result'] ) ? $status['result'] : array();

        // Strip ACTION_TAKEN: prefix from final content if API embedded it
        if ( ! empty( $result['post_content_html'] ) ) {
            $stripped = self::strip_action_taken( $result['post_content_html'] );
            $result['post_content_html'] = $stripped['content'];
        }

        // Build summary and list of completed checks from steps
        $categories      = Rank_Booster::get_check_categories();
        $summary_parts   = array();
        $completed_checks = array();
        if ( ! empty( $status['steps'] ) ) {
            foreach ( $status['steps'] as $step ) {
                if ( $step['status'] === 'completed' ) {
                    $completed_checks[] = $step['check'];
                    $label = isset( $categories[ $step['check'] ] ) ? $categories[ $step['check'] ] : $step['check'];
                    $action = ! empty( $step['actionTaken'] ) ? $step['actionTaken'] : 'Completed';
                    $summary_parts[] = '<li><strong>' . esc_html( $label ) . ':</strong> ' . esc_html( $action ) . '</li>';
                }
            }
        }
        $summary_html = $summary_parts ? '<ul>' . implode( '', $summary_parts ) . '</ul>' : '';

        // Store analysis data
        $analysis = isset( $result['analysisData'] ) ? wp_json_encode( $result['analysisData'] ) : null;

        // Validate block/shortcode integrity before storing
        $job_row = Rank_Booster_DB::get_job( $job_id );
        if ( $job_row && ! empty( $result['post_content_html'] ) ) {
            $original_content = get_post_field( 'post_content', (int) $job_row->post_id );
            if ( $job_row->editor_type === 'gutenberg' ) {
                $valid = self::validate_gutenberg_content( $original_content, $result['post_content_html'] );
                if ( is_wp_error( $valid ) ) {
                    Rank_Booster_DB::add_log_entry( $job_id, '⚠️ ' . $valid->get_error_message() );
                }
            } elseif ( $job_row->editor_type === 'divi' ) {
                $valid = self::validate_divi_content( $original_content, $result['post_content_html'] );
                if ( is_wp_error( $valid ) ) {
                    Rank_Booster_DB::add_log_entry( $job_id, '⚠️ ' . $valid->get_error_message() );
                }
            } elseif ( $job_row->editor_type === 'wpbakery' ) {
                $valid = Rank_Booster_WPBakery::validate_content( $original_content, $result['post_content_html'] );
                if ( is_wp_error( $valid ) ) {
                    Rank_Booster_DB::add_log_entry( $job_id, '⚠️ ' . $valid->get_error_message() );
                }
            }
        }

        $update = array(
            'status'        => 'completed',
            'draft_content' => isset( $result['post_content_html'] ) ? $result['post_content_html'] : '',
            'summary_html'  => $summary_html,
            'analysis_data' => $analysis,
        );

        // Only store title/meta/h1 if their corresponding checks actually ran.
        // h1_header → draft_title (WP post title / H1), used by preview + push live.
        // title_post → SEO title tag (separate from WP post title).
        if ( in_array( 'improve_header', $completed_checks, true ) && ! empty( $result['h1_header'] ) ) {
            $update['draft_title'] = $result['h1_header'];
            $update['draft_h1']    = $result['h1_header'];
        }
        if ( in_array( 'improve_title_tag', $completed_checks, true ) && ! empty( $result['title_post'] ) ) {
            $update['draft_title_tag'] = $result['title_post'];
        }
        if ( in_array( 'improve_meta_description', $completed_checks, true ) && ! empty( $result['meta_post'] ) ) {
            $update['draft_meta'] = $result['meta_post'];
        }

        if ( get_option( 'rank_debug_enabled', false ) ) {
            error_log( 'Rank Booster store_orchestration_results: job_id=' . $job_id . ' completed_checks=' . implode( ',', $completed_checks ) );
        }

        Rank_Booster_DB::update_job( $job_id, $update );

        $job = Rank_Booster_DB::get_job( $job_id );
        Rank_Booster_DB::add_log_entry( $job_id, '✓ Optimization complete for ' . ( $job ? $job->url : '(unknown)' ) );

        // Schedule the next queued job in this session
        if ( $job ) {
            self::schedule_session( $job->session_id );
        }
    }

    private static function store_analysis_only( $job_id, $result ) {
        $analysis = isset( $result['analysisData'] ) ? wp_json_encode( $result['analysisData'] ) : null;
        Rank_Booster_DB::update_job( $job_id, array(
            'status'        => 'completed',
            'analysis_data' => $analysis,
            'summary_html'  => '<p>No improvements needed — all checks passed.</p>',
        ) );

        // Schedule the next queued job in this session
        $job = Rank_Booster_DB::get_job( $job_id );
        if ( $job ) {
            self::schedule_session( $job->session_id );
        }
    }

    /**
     * Validate that Gutenberg block structure survived the API round-trip.
     *
     * @param string $original Original post_content with block comments.
     * @param string $returned Content returned from the API.
     * @return true|WP_Error True if OK, WP_Error describing what broke.
     */
    private static function validate_gutenberg_content( $original, $returned ) {
        $orig_blocks = parse_blocks( $original );
        $orig_count  = count( array_filter( $orig_blocks, function( $b ) {
            return ! empty( $b['blockName'] );
        } ) );

        // Only flag if the original had blocks but ALL were stripped — a true corruption.
        // Block count reductions are expected for rewrites (content_rewrite, add_faq, etc.)
        // so we no longer warn on count drops.
        if ( $orig_count > 0 && ! has_blocks( $returned ) ) {
            return new WP_Error( 'blocks_lost', 'Gutenberg block comments were stripped by the API. Draft stored but may break the editor.' );
        }

        return true;
    }

    /**
     * Validate that Divi shortcode structure survived the API round-trip.
     *
     * @param string $original Original post_content with Divi shortcodes.
     * @param string $returned Content returned from the API.
     * @return true|WP_Error True if OK, WP_Error describing what broke.
     */
    private static function validate_divi_content( $original, $returned ) {
        $orig_count = preg_match_all( '/\[et_pb_/', $original );
        if ( $orig_count > 0 && preg_match_all( '/\[et_pb_/', $returned ) === 0 ) {
            return new WP_Error( 'shortcodes_lost', 'Divi shortcodes were stripped by the API. Draft stored but may break the builder.' );
        }
        return true;
    }

    /**
     * Strip ACTION_TAKEN: prefix from API-returned content.
     *
     * Some API endpoints embed the action description as:
     *   ACTION_TAKEN: <text>\n---\n<actual HTML content>
     *
     * This extracts the action text and returns clean content.
     *
     * @param string $content Raw post_content_html from API.
     * @return array [ 'content' => clean HTML, 'action' => extracted text or '' ]
     */
    private static function strip_action_taken( $content ) {
        $action = '';
        $clean  = preg_replace_callback(
            '/ACTION_TAKEN:\s*(.+?)\n---\n/s',
            function ( $m ) use ( &$action ) {
                if ( empty( $action ) ) {
                    $action = trim( $m[1] );
                }
                return '';
            },
            $content
        );
        return array( 'content' => trim( $clean ), 'action' => $action );
    }

    private static function fail_job( $job_id, $message ) {
        Rank_Booster_DB::update_job( $job_id, array(
            'status'          => 'failed',
            'api_job_id'      => null,
            'manual_progress' => null,
            'error_message'   => $message,
        ) );
        Rank_Booster_DB::add_log_entry( $job_id, '✗ Error: ' . $message );

        // Schedule the next queued job in this session (don't stall on failure)
        $job = Rank_Booster_DB::get_job( $job_id );
        if ( $job ) {
            self::schedule_session( $job->session_id );
        }
    }
}
