<?php

if ( ! defined( 'ABSPATH' ) ) {

    exit;

}



/**

 * Admin-side Quick Post runner.

 *

 * Handles the stepwise manual posting flow over AJAX: start, AI generation,

 * duplicate checks, post creation, featured image generation, SEO finalization,

 * status checks, and cancellation.

 */

class AIAP_Admin_Runner {



    private static function read_manual_overrides() {

        $overrides = array();

        $uid = get_current_user_id();



        if ( isset( $_POST['manual_content_language'] ) ) {

            $lang = sanitize_text_field( wp_unslash( $_POST['manual_content_language'] ) );

            $valid = array_keys( AIAP_AI_Client::get_language_options() );

            if ( in_array( $lang, $valid, true ) ) {

                $overrides['content_language'] = $lang;

                update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_LANGUAGE, $lang );

            }

        }



        if ( isset( $_POST['manual_content_length'] ) ) {

            $raw_len = wp_unslash( $_POST['manual_content_length'] );

            if ( $raw_len !== '' ) {

                $len = max( 0, min( 10000, absint( $raw_len ) ) );

                update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_LENGTH, $len );

                if ( $len > 0 ) {

                    $overrides['content_length'] = $len;

                }

            }

        }



        $has_manual_rss_field = isset( $_POST['manual_rss_form_submitted'] )

            || isset( $_POST['manual_rss_enabled'] )

            || isset( $_POST['manual_rss_feed_ids'] )

            || isset( $_POST['manual_rss_count'] )

            || isset( $_POST['manual_rss_mode'] )

            || isset( $_POST['manual_rss_fallback'] );

        if ( $has_manual_rss_field ) {

            // Bug fix: 配列要素ごとに sanitize_key を適用する。下流の get_rss_settings の

            // 型/キー検証に頼らず、入口で feed_id として妥当な英数字+_- だけを通す。

            $manual_feed_ids_raw = isset( $_POST['manual_rss_feed_ids'] ) ? (array) wp_unslash( $_POST['manual_rss_feed_ids'] ) : array();

            $manual_feed_ids     = array_values( array_filter( array_map( 'sanitize_key', $manual_feed_ids_raw ), 'strlen' ) );

            $rss_input = array(

                'rss_enabled'  => isset( $_POST['manual_rss_enabled'] ) ? 1 : 0,

                'rss_feed_ids' => $manual_feed_ids,

                'rss_count'    => isset( $_POST['manual_rss_count'] ) ? $_POST['manual_rss_count'] : 5,

                'rss_mode'     => isset( $_POST['manual_rss_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['manual_rss_mode'] ) ) : 'pick',

                'rss_fallback' => isset( $_POST['manual_rss_fallback'] ) ? sanitize_text_field( wp_unslash( $_POST['manual_rss_fallback'] ) ) : 'generate',

            );

            $rss_normalized = AIAP_Prompts::get_rss_settings( $rss_input );

            $overrides['manual_rss_settings'] = $rss_normalized;

            update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_RSS_ENABLED,  (int) $rss_normalized['rss_enabled'] );

            update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_RSS_FEED_IDS, $rss_normalized['rss_feed_ids'] );

            update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_RSS_COUNT,    (int) $rss_normalized['rss_count'] );

            update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_RSS_MODE,     $rss_normalized['rss_mode'] );

            update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_RSS_FALLBACK, $rss_normalized['rss_fallback'] );

        }



        return $overrides;

    }



    // ========== Stepwise manual run (AJAX) ==========

    const RUN_STATE_TRANSIENT_PREFIX = 'aiap_run_state_';

    const RUN_STATE_TTL = 600; // 10 minutes

    const RUN_STATE_BUSY_THRESHOLD = 60;



    /**

     * Token for the scheduler_run lock acquired during manual AJAX runs.

     *

     * wp_send_json_* ends the request, so lock release is handled by a shutdown

     * function instead of relying on try/finally.

     */

    private static $scheduler_run_lock_token = null;



    private static function run_state_key() {

        return self::RUN_STATE_TRANSIENT_PREFIX . get_current_user_id();

    }



    private static function get_run_state() {

        $state = get_transient( self::run_state_key() );

        return is_array( $state ) ? $state : array();

    }



    private static function set_run_state( array $state ) {

        $state['updated_at'] = time();

        set_transient( self::run_state_key(), $state, self::RUN_STATE_TTL );

    }



    private static function clear_run_state() {

        delete_transient( self::run_state_key() );

    }



    private static function add_image_warning_to_state( array $state, $message ) {

        if ( empty( $state['warnings'] ) || ! is_array( $state['warnings'] ) ) {

            $state['warnings'] = array();

        }



        $state['warnings'][] = sprintf(

            __( 'Featured image generation failed, but the post was created (%s)', 'ai-auto-poster' ),

            (string) $message

        );



        return $state;

    }



    private static function step_payload( $next_step, $message, array $extra = array() ) {

        return array_merge( array(

            'next_step' => $next_step,

            'message'   => $message,

        ), $extra );

    }



    private static function post_step_payload( $dedup_attempts ) {

        return self::step_payload(

            'post',

            __( 'Creating post…', 'ai-auto-poster' ),

            array(

                'retry_needed'   => false,

                'dedup_attempts' => $dedup_attempts,

            )

        );

    }



    private static function image_step_payload( $post_id ) {

        return self::step_payload(

            'image',

            __( 'Generating featured image…', 'ai-auto-poster' ),

            array( 'post_id' => (int) $post_id )

        );

    }



    private static function seo_step_payload( array $extra = array() ) {

        return self::step_payload(

            'seo',

            __( 'Applying SEO settings…', 'ai-auto-poster' ),

            $extra

        );

    }



    /**

     * Raise runtime limits for long manual runs.

     *

     * AI generation, image retries, and duplicate retries can exceed common PHP

     * execution limits. Keep the process alive after client disconnects and ask

     * WordPress for a higher memory limit when available.

     */

    private static function raise_long_running_limits() {

        if ( function_exists( 'set_time_limit' ) ) {

            @set_time_limit( 0 );

        }

        if ( function_exists( 'ignore_user_abort' ) ) {

            @ignore_user_abort( true );

        }

        if ( function_exists( 'wp_raise_memory_limit' ) ) {

            wp_raise_memory_limit( 'cron' );

        }

    }



    /**

     * Convert fatal errors during AJAX execution into logged JSON responses.

     */

    public static function fatal_error_handler() {

        $err = error_get_last();

        if ( ! $err ) return;

        $fatal_types = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR );

        if ( ! in_array( $err['type'], $fatal_types, true ) ) return;



        $msg = sprintf(

            /* translators: 1: PHP error message, 2: file basename, 3: line number */

            __( 'PHP Fatal: %1$s in %2$s:%3$d', 'ai-auto-poster' ),

            $err['message'],

            basename( $err['file'] ),

            $err['line']

        );



        if ( class_exists( 'AIAP_Logger' ) ) {

            AIAP_Logger::add( 'error', 'log_manual_fatal', array( $msg ) );

        }



        $action = isset( $_POST['action'] ) ? (string) wp_unslash( $_POST['action'] ) : '';

        $step   = isset( $_POST['step'] ) ? (string) wp_unslash( $_POST['step'] ) : '';

        if ( $action === 'aiap_run_now_step' && $step === 'image' ) {

            $state = self::get_run_state();

            if ( is_array( $state ) && ! empty( $state['post_id'] ) ) {

                $state = self::add_image_warning_to_state( $state, $msg );

                self::set_run_state( $state );



                if ( headers_sent() ) return;

                if ( ! function_exists( 'wp_send_json_success' ) ) return;

                if ( function_exists( 'http_response_code' ) ) {

                    http_response_code( 200 );

                }

                wp_send_json_success( self::seo_step_payload( array( 'warnings' => $state['warnings'] ) ) );

            }

        }



        self::clear_run_state();



        if ( headers_sent() ) return;

        if ( ! function_exists( 'wp_send_json_error' ) ) return;



        if ( function_exists( 'http_response_code' ) ) {

            http_response_code( 200 );

        }

        wp_send_json_error( array( 'step' => 'ai', 'message' => $msg ) );

    }



    /**

     * Release the scheduler_run lock from the shutdown handler.

     */

    public static function release_scheduler_run_lock() {

        if ( self::$scheduler_run_lock_token !== null

            && self::$scheduler_run_lock_token !== false

            && self::$scheduler_run_lock_token !== ''

            && class_exists( 'AIAP_Lock' ) ) {

            AIAP_Lock::release( AIAP_Lock_Keys::SCHEDULER_RUN, self::$scheduler_run_lock_token );

        }

        self::$scheduler_run_lock_token = null;

    }



    /**

     * Return whether the current user's Quick Post run is still in progress.

     *

     * This endpoint is intentionally read-only. It must not refresh updated_at,

     * otherwise an abandoned in-progress state can be extended by an open admin

     * screen.

     */

    public static function ajax_run_now_status() {

        AIAP_Admin::ajax_verify();



        $state = self::get_run_state();



        if ( empty( $state ) || empty( $state['started_at'] ) ) {

            wp_send_json_success( array( 'in_progress' => false ) );

        }



        $started_at = (int) $state['started_at'];

        $age        = time() - $started_at;



        if ( $age < 0 || $age >= self::RUN_STATE_TTL ) {

            wp_send_json_success( array( 'in_progress' => false ) );

        }



        $lock_held = AIAP_Lock::is_held( AIAP_Lock_Keys::SCHEDULER_RUN );



        // Status polling is read-only. Do not refresh updated_at here; otherwise a

        // stale in-progress state can be extended forever by an open admin screen.

        wp_send_json_success( array(

            'in_progress'     => true,

            'started_at'      => $started_at,

            'age_seconds'     => $age,

            'lock_held'       => $lock_held,

            'idempotency_key' => isset( $state['idempotency_key'] ) ? (string) $state['idempotency_key'] : '',

        ) );

    }



    /**

     * Cancel the current Quick Post run and clear the transient state.

     */

    public static function ajax_run_now_cancel() {

        AIAP_Admin::ajax_verify();



        $state = self::get_run_state();

        if ( is_array( $state ) && ! empty( $state ) ) {

            AIAP_Poster::log_run_cancelled( 'src_manual' );

        }



        self::clear_run_state();



        wp_send_json_success( array( 'cancelled' => true ) );

    }



    public static function ajax_run_now_clear() {

        AIAP_Admin::ajax_verify();



        self::clear_run_state();



        wp_send_json_success( array( 'cleared' => true ) );

    }



    public static function ajax_run_now_start() {

        register_shutdown_function( array( __CLASS__, 'fatal_error_handler' ) );

        self::raise_long_running_limits();

        AIAP_Admin::ajax_verify();



        if ( ! AIAP_License::can_post_more() ) {

            wp_send_json_error( array(

                'step'    => 'limit',

                'message' => AIAP_License::limit_reached_message(),

                'limit'   => AIAP_License::FREE_MONTHLY_LIMIT,

                'count'   => AIAP_License::monthly_post_count(),

                'upgrade' => AIAP_License::upgrade_url(),

            ) );

        }



        // A fresh heartbeat means another tab is still running a manual post.

        $existing_state = self::get_run_state();

        if ( $existing_state ) {

            $heartbeat = isset( $existing_state['updated_at'] )

                ? (int) $existing_state['updated_at']

                : (int) ( $existing_state['started_at'] ?? 0 );

            $age = time() - $heartbeat;

            if ( $heartbeat > 0 && $age >= 0 && $age < self::RUN_STATE_BUSY_THRESHOLD ) {

                wp_send_json_error( array(

                    'step'    => 'busy',

                    'message' => __( 'A Quick Post is already in progress in another tab. Please wait or refresh.', 'ai-auto-poster' ),

                ) );

            }

            self::clear_run_state();

        }



        // Arguments: key, ttl_seconds, retries, sleep_microseconds.

        $sr_lock = false;

        if ( class_exists( 'AIAP_Lock' ) ) {

            $sr_lock = AIAP_Lock::acquire_with_retry( AIAP_Lock_Keys::SCHEDULER_RUN, 300, 10, 1000000 );

            if ( $sr_lock === false ) {

                wp_send_json_error( array(

                    'step'    => 'busy',

                    'message' => __( 'AI Auto Poster is busy running another post. Please try again shortly.', 'ai-auto-poster' ),

                ) );

            }

            self::$scheduler_run_lock_token = $sr_lock;

            register_shutdown_function( array( __CLASS__, 'release_scheduler_run_lock' ) );

        }



        $prompt  = sanitize_textarea_field( wp_unslash( $_POST['manual_prompt'] ?? '' ) );

        $task_id = sanitize_text_field( wp_unslash( $_POST['manual_selected_task_id'] ?? '' ) );

        $overrides = self::read_manual_overrides();

        $featured_image_mode = AIAP_Prompts::sanitize_image_mode( wp_unslash( $_POST['manual_featured_image_mode'] ?? 'on' ) );

        update_user_meta( get_current_user_id(), AIAP_Admin::USER_META_LAST_MANUAL_IMAGE_MODE, $featured_image_mode );



        $im_settings_qs = AIAP_Image_Client::get_settings();

        $overrides['image_enabled_effective'] = AIAP_Image_Client::resolve_image_enabled(

            $featured_image_mode,

            $im_settings_qs['image_enabled'] ?? 0

        );



        update_user_meta( get_current_user_id(), AIAP_Admin::USER_META_LAST_MANUAL_PROMPT, $prompt );



        if ( $prompt === '' ) {

            wp_send_json_error( array( 'message' => __( 'Prompt is required.', 'ai-auto-poster' ) ) );

        }



        if ( $task_id !== '' ) {

            $sel_task = AIAP_Prompts::get_by_id( $task_id );

            if ( ! is_array( $sel_task ) ) {

                $task_id = '';

            }

        }



        // Normalize the task label for the start log.

        $run_started_label = $task_id !== '' ? AIAP_Prompts::format_log_label( $task_id ) : '';

        if ( $run_started_label === '-' ) {

            $run_started_label = '';

        }

        AIAP_Poster::log_run_started( 'src_manual', array( 'task_label' => $run_started_label ) );





        $qp_rss_settings   = isset( $overrides['manual_rss_settings'] ) && is_array( $overrides['manual_rss_settings'] )

            ? $overrides['manual_rss_settings']

            : AIAP_Prompts::get_rss_settings( array() );

        $qp_rss_used_articles = array();

        if ( $qp_rss_settings['rss_enabled'] === 1 ) {

            $qp_is_pro = class_exists( 'AIAP_License' ) && AIAP_License::is_pro();

            if ( ! $qp_is_pro ) {

                AIAP_Logger::add( 'info', 'log_rss_skipped_free_plan', array( 'src_manual', '-' ) );

            } else {

                $qp_candidates = AIAP_RSS_Feeds::pick_unused( $qp_rss_settings['rss_feed_ids'], $qp_rss_settings['rss_count'] );

                if ( empty( $qp_candidates ) ) {

                    if ( $qp_rss_settings['rss_fallback'] === 'skip' ) {

                        AIAP_Logger::add( 'info', 'log_rss_skipped_no_articles', array( 'src_manual', '-' ) );

                        wp_send_json_error( array(

                            'step'    => 'rss',

                            'message' => __( 'No fresh RSS articles available; post skipped per task setting.', 'ai-auto-poster' ),

                        ) );

                    }

                    AIAP_Logger::add( 'info', 'log_rss_fallback_to_normal', array( 'src_manual', '-' ) );

                } else {

                    $overrides['rss_articles']  = $qp_candidates;

                    $overrides['rss_mode']      = $qp_rss_settings['rss_mode'];

                    $qp_rss_used_articles       = $qp_candidates;

                }

            }

        }



        $idempotency_key_raw = isset( $_POST['idempotency_key'] ) ? wp_unslash( $_POST['idempotency_key'] ) : '';

        $idempotency_key = preg_match( '/^[a-zA-Z0-9_\-]{8,64}$/', (string) $idempotency_key_raw )

            ? (string) $idempotency_key_raw

            : ( function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'aiap_idem_', true ) );



        self::set_run_state( array(

            'original_prompt'   => $prompt,

            'overrides'         => $overrides,

            'task_id'           => $task_id,

            'rss_used_articles' => $qp_rss_used_articles,

            'rss_settings'      => $qp_rss_settings,

            'started_at'        => time(),

            'idempotency_key'   => $idempotency_key,

        ) );



        $server_retry_count = max( 0, absint( wp_unslash( $_POST['server_retry_count'] ?? 0 ) ) );

        self::do_single_generate_and_check( $prompt, 1, 0, $server_retry_count );

    }



    /**

     * Retry AI generation after a retryable AI/server error or duplicate check.

     */

    public static function ajax_run_now_retry() {

        register_shutdown_function( array( __CLASS__, 'fatal_error_handler' ) );

        self::raise_long_running_limits();

        AIAP_Admin::ajax_verify();



        $state = self::get_run_state();

        if ( empty( $state['original_prompt'] ) ) {

            wp_send_json_error( array( 'message' => __( 'Run state expired. Please retry.', 'ai-auto-poster' ) ) );

        }



        $sr_lock = false;

        if ( class_exists( 'AIAP_Lock' ) ) {

            $sr_lock = AIAP_Lock::acquire_with_retry( AIAP_Lock_Keys::SCHEDULER_RUN, 300, 10, 1000000 );

            if ( $sr_lock === false ) {

                wp_send_json_error( array(

                    'step'    => 'busy',

                    'message' => __( 'AI Auto Poster is busy running another post. Please try again shortly.', 'ai-auto-poster' ),

                ) );

            }

            self::$scheduler_run_lock_token = $sr_lock;

            register_shutdown_function( array( __CLASS__, 'release_scheduler_run_lock' ) );

        }



        $attempt             = max( 1, absint( wp_unslash( $_POST['attempt'] ?? 1 ) ) );

            $dedup_attempts      = max( 0, absint( wp_unslash( $_POST['dedup_attempts'] ?? 0 ) ) );

            $retry_prompt        = sanitize_textarea_field( wp_unslash( $_POST['retry_prompt'] ?? '' ) );

            $server_retry_count  = max( 0, absint( wp_unslash( $_POST['server_retry_count'] ?? 0 ) ) );



            $use_prompt = $retry_prompt !== '' ? $retry_prompt : $state['original_prompt'];

            self::do_single_generate_and_check( $use_prompt, $attempt, $dedup_attempts, $server_retry_count );

    }



    /**

     * Run one AI generation attempt and decide whether to post, retry, or fail.

     */

    private static function do_single_generate_and_check( $current_prompt, $attempt, $dedup_attempts, $server_retry_count = 0 ) {

        $state = self::get_run_state();

        $original_prompt = isset( $state['original_prompt'] ) ? $state['original_prompt'] : $current_prompt;

        $overrides       = isset( $state['overrides'] ) ? $state['overrides'] : array();



        $settings      = AIAP_AI_Client::get_settings();

        // Bug fix (audit r4): mirror Pro gate from AIAP_Poster::run_single() so manual

        // runs do not silently exercise dedup (a Pro feature) on Free plan when the

        // saved dedup_enabled=1 was carried over from a previous Pro subscription.

        $dedup_enabled = ! empty( $settings['dedup_enabled'] )

            && ( ! class_exists( 'AIAP_License' ) || AIAP_License::is_pro() );

        $max_retry     = max( 1, (int) ( $settings['dedup_max_retry'] ?? 3 ) );



        $result = AIAP_AI_Client::generate( $current_prompt, $overrides, true );

        // 文字数不足フォールバック (リトライ枯渇時のみ):
        // generate() は本文・タイトルが揃っていても文字数が最低字数未満なら
        // content_too_short の WP_Error を返し、その data に有効な生成結果
        // (fallback_result) を載せている。サーバー側リトライ上限に達している場合は、
        // 「記事なし」より「やや短い記事」を優先して、その結果を採用し通常の成功経路
        // (dedup / 投稿) へ合流させる。
        if ( is_wp_error( $result )
            && $server_retry_count >= AIAP_AI_Client::GENERATE_MAX_RETRY ) {
            $short_fallback = AIAP_AI_Client::extract_short_content_fallback( $result );
            if ( $short_fallback !== null ) {
                $result = $short_fallback;
            }
        }

        if ( is_wp_error( $result ) ) {
            $err_data    = $result->get_error_data();
            // Bug fix (audit r12): generate_with_retry と同じ判定ロジックに統一。

            // 旧実装はデフォルト false で、parse_error 等 retryable キーを明示していない

            // WP_Error が「1 回試行で諦め」になっていた (Quick Post とスケジュールで挙動が

            // 矛盾していた)。新方針:

            //   1. retryable が明示的に false なら false (= 永続エラー、例: auth_error/quota_exhausted)

            //   2. http_code が明示的にリトライ不可コードなら false

            //   3. それ以外 (= http_code=0 含む) は true (リトライ可能扱い)

            // これにより parse_error (data なし) も自然とリトライされる。

            $is_retryable = true;

            $http_code    = is_array( $err_data ) && isset( $err_data['http_code'] ) ? (int) $err_data['http_code'] : 0;

            if ( is_array( $err_data ) && array_key_exists( 'retryable', $err_data ) && ! $err_data['retryable'] ) {

                $is_retryable = false;

            } elseif ( $http_code !== 0 && ! AIAP_AI_Errors::is_retryable_http_code( $http_code ) ) {

                $is_retryable = false;

            }

            $kind         = is_array( $err_data ) && isset( $err_data['kind'] ) ? (string) $err_data['kind'] : 'unknown';

            $task_label   = AIAP_Prompts::format_log_label( $state['task_id'] ?? '' );



            $max_retry_ai = AIAP_AI_Client::GENERATE_MAX_RETRY;

            if ( $is_retryable && $server_retry_count >= $max_retry_ai ) {

                $is_retryable = false;

            }



            // Count the current failed attempt before deciding the next client action.

            $attempts_so_far = $server_retry_count + 1;

            if ( $is_retryable ) {

                AIAP_Logger::add( 'warning', 'log_server_error_retry', array(

                    'src_manual', $attempts_so_far, $max_retry_ai + 1,

                    AIAP_AI_Errors::format_error_for_log( $result ), $task_label,

                ) );

            } else {

                AIAP_Logger::add( 'error', 'log_server_error_failed', array(

                    'src_manual', $attempts_so_far,

                    AIAP_AI_Errors::format_error_for_log( $result ), $task_label,

                ) );

            }



            if ( ! $is_retryable ) {

                self::clear_run_state();

                wp_send_json_error( array(

                    'step'      => 'ai',

                    'message'   => $result->get_error_message(),

                    'retryable' => false,

                    'kind'      => $kind,

                ) );

            }



            $retry_after_value = is_array( $err_data ) && isset( $err_data['retry_after'] ) ? (int) $err_data['retry_after'] : 0;

            $next_sleep        = AIAP_AI_Errors::compute_retry_sleep_seconds( $server_retry_count + 1, $retry_after_value );

            wp_send_json_error( array(
                'step'               => 'ai',
                'message'            => $result->get_error_message(),
                'retryable'          => true,
                'kind'               => $kind,
                'retry_prompt'       => is_array( $err_data ) && ! empty( $err_data['retry_prompt'] ) ? (string) $err_data['retry_prompt'] : '',
                'next_sleep_seconds' => $next_sleep,
            ) );
        }


        if ( ! $dedup_enabled ) {

            self::set_run_state( array_merge( $state, array(

                'result'   => $result,

                'prompt'   => $original_prompt,

                'post_id'  => 0,

                'warnings' => array(),

            ) ) );

            wp_send_json_success( self::post_step_payload( $dedup_attempts ) );

        }



        $dedup_result = AIAP_Dedup::check( $result['title'], $result['content'] );

        $outcome      = AIAP_Dedup::classify_attempt_outcome( $dedup_result, $attempt, $max_retry, 'src_manual' );



        if ( $outcome === 'accept' ) {

            self::set_run_state( array_merge( $state, array(

                'result'   => $result,

                'prompt'   => $original_prompt,

                'post_id'  => 0,

                'warnings' => array(),

            ) ) );

            wp_send_json_success( self::post_step_payload( $dedup_attempts ) );

        }



        if ( $outcome === 'give_up_accept' ) {

            $dedup_attempts++;

            self::set_run_state( array_merge( $state, array(

                'result'   => $result,

                'prompt'   => $original_prompt,

                'post_id'  => 0,

                'warnings' => array( __( 'Could not fully avoid similarity. Posted with the latest generation.', 'ai-auto-poster' ) ),

            ) ) );

            wp_send_json_success( self::post_step_payload( $dedup_attempts ) );

        }



        $dedup_attempts++;

        $recent       = AIAP_Dedup::get_recent_posts();

        $retry_prompt = AIAP_Dedup::build_retry_prompt(

            $original_prompt,

            $dedup_result['reason'],

            $recent,

            $result

        );



        wp_send_json_success( array(

            'retry_needed'   => true,

            'kind'           => 'dedup',

            'next_attempt'   => $attempt + 1,

            'retry_prompt'   => $retry_prompt,

            'dedup_attempts' => $dedup_attempts,

            'reason'         => $dedup_result['reason'],

        ) );

    }



    public static function ajax_run_now_step() {

        register_shutdown_function( array( __CLASS__, 'fatal_error_handler' ) );

        self::raise_long_running_limits();

        AIAP_Admin::ajax_verify();

        $step  = sanitize_key( wp_unslash( $_POST['step'] ?? '' ) );

        $state = self::get_run_state();

        if ( empty( $state ) ) {

            wp_send_json_error( array( 'message' => __( 'State expired. Please retry.', 'ai-auto-poster' ) ) );

        }



        switch ( $step ) {

            case 'post':

                // 自動 cron (AIAP_Scheduler::run) との二重投稿を防ぐため、実際に投稿を

                // 作成するこのステップでも scheduler_run ロックを取得する。start ステップで

                // 取得したロックは当該リクエスト終了時に解放済みで、post は別 AJAX

                // リクエストのため、ここで改めて取得する (解放は shutdown で行う)。

                if ( class_exists( 'AIAP_Lock' ) ) {

                    $sr_lock = AIAP_Lock::acquire_with_retry( AIAP_Lock_Keys::SCHEDULER_RUN, 300, 10, 1000000 );

                    if ( $sr_lock === false ) {

                        wp_send_json_error( array(

                            'step'    => 'busy',

                            'message' => __( 'AI Auto Poster is busy running another post. Please try again shortly.', 'ai-auto-poster' ),

                        ) );

                    }

                    self::$scheduler_run_lock_token = $sr_lock;

                    register_shutdown_function( array( __CLASS__, 'release_scheduler_run_lock' ) );

                }

                $result = $state['result'];

                $ai     = AIAP_AI_Client::get_settings();



                $idempotency_key = isset( $state['idempotency_key'] ) ? (string) $state['idempotency_key'] : '';

                if ( $idempotency_key !== '' ) {

                    $existing_q = new WP_Query( array(

                        'post_type'      => 'post',

                        'post_status'    => array( 'publish', 'draft', 'pending', 'future' ),

                        'posts_per_page' => 1,

                        'fields'         => 'ids',

                        'no_found_rows'  => true,

                        'meta_query'     => array(

                            array(

                                'key'   => '_aiap_idempotency_key',

                                'value' => $idempotency_key,

                            ),

                        ),

                    ) );

                    if ( ! empty( $existing_q->posts ) ) {

                        $existing_pid = (int) $existing_q->posts[0];

                        AIAP_Logger::add( 'info', 'log_manual_idempotent_hit', array( $existing_pid, $idempotency_key ) );

                        $state['post_id'] = $existing_pid;

                        self::set_run_state( $state );

                        $payload = ! empty( $state['overrides']['image_enabled_effective'] )

                            ? self::image_step_payload( $existing_pid )

                            : self::seo_step_payload( array( 'post_id' => $existing_pid ) );

                        $payload['step']      = 'post';

                        $payload['post_id']   = $existing_pid;

                        $payload['idempotent'] = true;

                        $payload['message']   = __( 'Post was already created (idempotent reuse).', 'ai-auto-poster' );

                        wp_send_json_success( $payload );

                    }

                }



                $task_for_phase = array(

                    'id'     => isset( $state['task_id'] ) ? (string) $state['task_id'] : '',

                    'prompt' => isset( $state['original_prompt'] ) ? (string) $state['original_prompt'] : '',

                );



                $extra_meta = AIAP_Poster::build_extra_meta(

                    'src_manual',

                    $task_for_phase,

                    null,

                    '',

                    array( 'idempotency_key' => $idempotency_key )

                );



                $rss_used     = isset( $state['rss_used_articles'] ) && is_array( $state['rss_used_articles'] ) ? $state['rss_used_articles'] : array();

                $rss_settings = isset( $state['rss_settings'] ) && is_array( $state['rss_settings'] ) ? $state['rss_settings'] : array( 'rss_mode' => 'pick' );

                $rss_consumed = AIAP_Poster::resolve_rss_consumed( $rss_used, $rss_settings, $result, 'src_manual' );

                // pick モードで採用しなかった予約候補を解放 (TTL まで残ると他経路の pick をブロックする)

                AIAP_Poster::release_unconsumed_reservations( $rss_used, $rss_consumed );



                // Phase: create the post, store metadata, increment counters, and mark RSS items used.

                $pid = AIAP_Poster::phase_create_post(

                    $result, $task_for_phase, $ai, 'src_manual', '',

                    $rss_consumed, $rss_settings, $extra_meta, null

                );



                if ( is_wp_error( $pid ) ) {

                    AIAP_Logger::add( 'error', 'log_manual_post_error', array( $pid->get_error_message() ) );

                    self::clear_run_state();

                    wp_send_json_error( array( 'step' => 'post', 'message' => $pid->get_error_message() ) );

                }



                $state['post_id']      = (int) $pid;

                $state['slug']         = get_post_field( 'post_name', $pid );

                $state['ai']           = $ai;

                $state['rss_consumed'] = $rss_consumed;

                self::set_run_state( $state );





                if ( ! empty( $state['overrides']['image_enabled_effective'] ) ) {

                    wp_send_json_success( self::image_step_payload( $pid ) );

                }

                wp_send_json_success( self::seo_step_payload( array( 'post_id' => (int) $pid ) ) );

                break;



            case 'image':

                if ( empty( $state['post_id'] ) ) {

                    self::clear_run_state();

                    wp_send_json_error( array( 'message' => __( 'Out-of-order step. Please retry.', 'ai-auto-poster' ) ) );

                }

                $pid    = (int) $state['post_id'];



                // If the previous image AJAX response was lost after PHP completed,

                // the browser may retry this same step. Do not generate a second

                // featured image in that case; continue to the next phase.

                if ( ! empty( $state['image_step_done'] ) || has_post_thumbnail( $pid ) ) {

                    $state['image_step_done'] = 1;

                    self::set_run_state( $state );

                    wp_send_json_success( self::seo_step_payload( array(

                        'warnings' => isset( $state['warnings'] ) && is_array( $state['warnings'] ) ? $state['warnings'] : array(),

                    ) ) );

                }



                $result = $state['result'];

                $slug   = isset( $state['slug'] ) ? (string) $state['slug'] : '';

                if ( ! isset( $result['slug'] ) || $result['slug'] === '' ) {

                    $result['slug'] = $slug;

                }

                $task_for_phase = array(

                    'id'     => isset( $state['task_id'] ) ? (string) $state['task_id'] : '',

                    'prompt' => isset( $state['original_prompt'] ) ? (string) $state['original_prompt'] : '',

                );

                $effective_on = ! empty( $state['overrides']['image_enabled_effective'] ) ? 1 : 0;



                try {

                    $r = AIAP_Poster::phase_attach_image( $pid, $result, $task_for_phase, $effective_on, 'src_manual' );

                } catch ( Throwable $e ) {

                    $r = new WP_Error( 'image_step_failed', $e->getMessage() );

                    if ( class_exists( 'AIAP_Logger' ) ) {

                        AIAP_Logger::add( 'error', 'log_thumbnail_failed', array(

                            'src_manual',

                            $pid,

                            $e->getMessage(),

                        ) );

                    }

                }

                if ( is_wp_error( $r ) ) {

                    $state = self::add_image_warning_to_state( $state, $r->get_error_message() );

                }



                // Mark the image phase complete before sending the response. If the

                // browser reports Load failed after this point, a retry can safely

                // skip image generation and proceed to SEO/finalization.

                $state['image_step_done'] = 1;

                self::set_run_state( $state );



                wp_send_json_success( self::seo_step_payload( array(

                    'warnings' => isset( $state['warnings'] ) && is_array( $state['warnings'] ) ? $state['warnings'] : array(),

                ) ) );

                break;



            case 'seo':

                if ( empty( $state['post_id'] ) ) {

                    self::clear_run_state();

                    wp_send_json_error( array( 'message' => __( 'Out-of-order step. Please retry.', 'ai-auto-poster' ) ) );

                }

                $pid    = (int) $state['post_id'];

                $result = $state['result'];

                $ai     = $state['ai'] ?? AIAP_AI_Client::get_settings();



                $rss_consumed = isset( $state['rss_consumed'] ) && is_array( $state['rss_consumed'] ) ? $state['rss_consumed'] : array();

                $rss_settings = isset( $state['rss_settings'] ) && is_array( $state['rss_settings'] ) ? $state['rss_settings'] : array( 'rss_mode' => 'pick' );

                AIAP_Poster::phase_finalize(

                    $pid, $result, $ai, 'src_manual', '',

                    $rss_consumed, $rss_settings, null

                );



                wp_send_json_success( self::finalize_run( $state ) );

                break;



            default:

                self::clear_run_state();

                wp_send_json_error( array( 'message' => __( 'Unknown step.', 'ai-auto-poster' ) ) );

        }

    }



    private static function finalize_run( array $state ) {

        $pid      = (int) $state['post_id'];

        $result   = $state['result'];

        $warnings = $state['warnings'] ?? array();

        if ( is_array( $warnings ) ) {

            $warnings = array_values( array_unique( array_map( 'strval', $warnings ) ) );

        } else {

            $warnings = array();

        }



        $recovered = 0;

        if ( class_exists( 'AIAP_Scheduler' ) ) {

            $recovered = (int) AIAP_Scheduler::enqueue_missed_for_manual();

        }

        if ( $recovered > 0 ) {

            /* translators: %d = number of missed schedules detected */

            $warnings[] = sprintf( __( '%d missed schedule(s) detected. They will be posted automatically every 5 minutes.', 'ai-auto-poster' ),  $recovered ) ;

        }



        self::clear_run_state();

        return array(

            'next_step' => 'done',

            /* translators: %d = post ID of the newly created post */

            'message'   => sprintf( __( 'Post created (ID: %d)', 'ai-auto-poster' ), $pid ),

            'post_id'   => $pid,

            'edit_url'  => get_edit_post_link( $pid, 'raw' ),

            'warnings'  => $warnings,

        );

    }

}

