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

/**
 * 管理画面の Run Now (手動投稿実行) エンジン。
 *
 * クイック投稿の段階実行 (start/step/retry/status)、実行状態 transient の管理、
 * 単発生成+重複チェック、長時間処理向けの実行制限緩和、致命的エラー/シャット
 * ダウン時のロック解放を担う (Phase 3 ステップ6)。AIAP_Admin で最も状態結合の
 * 強かった部分を独立させたもの。
 *
 * 実行ロックトークンは本クラスの静的プロパティで保持する。nonce 検証は共有
 * ヘルパ AIAP_Admin::ajax_verify() を、USER_META/RUN_STATE 定数は AIAP_Admin の
 * 定数を参照する。AJAX フック登録は AIAP_Admin::init() が行う。
 */
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 ) ) );
                // 送信された値は常にユーザーメタに保存する(0=無制限を含む)
                update_user_meta( $uid, AIAP_Admin::USER_META_LAST_MANUAL_LENGTH, $len );
                // overrides に積むのは len > 0 のときだけ(0 はオーバーライドではなく既定値使用)
                if ( $len > 0 ) {
                    $overrides['content_length'] = $len;
                }
            }
        }

        // RSS 設定（Quick Post 専用）
        // フォームに manual_rss_enabled が来ていない場合は触らない（user_meta も更新しない）
        // Quick Post フォームから来た場合のみ RSS 設定を正規化＆保存する。
        // manual_rss_form_submitted 隠しフィールドが確実な判定。
        // フォールバック: いずれかの RSS フィールドが送られていれば旧挙動でも保存する。
        $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 ) {
            $rss_input = array(
                'rss_enabled'  => isset( $_POST['manual_rss_enabled'] ) ? 1 : 0,
                'rss_feed_ids' => isset( $_POST['manual_rss_feed_ids'] ) ? (array) wp_unslash( $_POST['manual_rss_feed_ids'] ) : array(),
                '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;
            // user_meta に最後の値を保存（次回画面復元用、フォーム入力のあった値だけ）
            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; // 秒。fresh 進行中と判定する閾値（2 タブ同時起動対策）

    /**
     * scheduler_run ロックの解放トークン保管用。
     * wp_send_json_*() は wp_die()->exit() で終了し try/finally の finally を実行しないため、
     * exit() でも確実に走る register_shutdown_function 経由でロックを解放する。
     * その shutdown ハンドラ release_scheduler_run_lock() がこのトークンを参照する。
     */
    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 ) {
        set_transient( self::run_state_key(), $state, self::RUN_STATE_TTL );
    }

    private static function clear_run_state() {
        delete_transient( self::run_state_key() );
    }

    /**
     * Bug fix (audit r9 self-audit): 長時間処理(AI 生成 + 画像 retry)の途中で
     * PHP の max_execution_time に達してプロセスが殺されないようにする。
     *
     * 観察された症状:
     *   - ユーザーが画像 502 で「サーバ障害対応中 1/3」のログを見たが
     *     2/3 と 3/3 のログがどこにも書かれていなかった。
     *   - Logger::add は即 update_option するため、書かれていない =
     *     プロセス自体がそこに到達する前に殺されている、と判断できる。
     *
     * 旧コードは set_time_limit / ignore_user_abort をどこでも呼んでいなかった。
     * デフォルトの max_execution_time は PHP 5.3 以降 30 秒。共用ホスティング
     * では 30〜60 秒に絞られていることが多い。一方で:
     *   - wp_remote_post( ..., array('timeout' => 120) ) を呼んでいる
     *   - call_with_retry が最大 4 回試行 + sleep(1+2+3) する
     *   - dedup retry や AI generate retry でさらに繰り返される
     * これが PHP 制限と衝突して、リトライ完了前にプロセスが死ぬ。
     *
     * 対策:
     *   - set_time_limit(0) で実行時間制限を解除(ホストが許可していない場合は @ で握る)
     *   - ignore_user_abort(true) でクライアントが切断しても処理継続
     *   - wp_raise_memory_limit('cron') でメモリ上限を引き上げ(画像処理対策)
     */
    private static function raise_long_running_limits() {
        if ( function_exists( 'set_time_limit' ) ) {
            // 共用ホスティングで disable_functions に入っていることがあるため warning を抑制。
            @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' );
        }
    }

    /**
     * Ajaxハンドラで Fatal Error が起きた時、最後にログを残して JSONエラーで返す
     * register_shutdown_function で登録する
     */
    public static function fatal_error_handler() {
        $err = error_get_last();
        if ( ! $err ) return;
        // Fatal系のみ反応
        $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 ) );
        }

        // すでにヘッダ送信済みなら応答できない
        if ( headers_sent() ) return;
        if ( ! function_exists( 'wp_send_json_error' ) ) return;

        self::clear_run_state();

        // 500を返さずJSONエラーとして応答(クライアントがちゃんと受け取れる)
        if ( function_exists( 'http_response_code' ) ) {
            http_response_code( 200 );
        }
        wp_send_json_error( array( 'step' => 'ai', 'message' => $msg ) );
    }

    /**
     * scheduler_run ロックを解放する shutdown ハンドラ。
     *
     * ajax_run_now_start() / ajax_run_now_retry() から register_shutdown_function で登録する。
     * do_single_generate_and_check() を含む手動投稿経路は必ず wp_send_json_*() (= wp_die ->
     * exit()) で終了するため try/finally の finally は実行されない (PHP 仕様)。
     * shutdown function は exit() でも正常終了でも致命的エラーでも実行されるため、
     * ここで解放することで scheduler_run ロックの TTL 切れ待ち (最大 300 秒) を待たずに
     * 直ちに解放され、後続の手動投稿が「使用中」で弾かれなくなる。
     *
     * 解放後はトークンを null に戻し、二重実行されても無害(冪等)にしている。
     */
    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;
    }

    /**
     * 進行中の手動実行があるかを返す軽量 endpoint
     *
     * 用途: ユーザーが「今すぐ実行」中に別タブ/別メニューに移動し、戻ってきた時の状態復元。
     *   - JS は DOMContentLoaded で本 endpoint を呼ぶ
     *   - in_progress=true なら「進行中」UI を表示してユーザーに状況を伝える
     *
     * 進行中の判定:
     *   (1) run_state が transient に残っている (= 全ステップ完了で clear_run_state されていない)
     *   (2) started_at が TTL 内
     *   (3) scheduler_run ロックがまだ取られている
     *
     * (1)(2) のいずれかが偽 → 完了済みまたは異常終了 → in_progress=false
     * (3) は補助情報。ロックが切れていてもサーバ側 PHP が ignore_user_abort で
     * まだ走っている可能性はあるが、run_state が残っていれば in_progress 扱いにする。
     *
     * AIAP_Lock とは別系統 (本 endpoint はロックを取得しない、read-only)。
     */
    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;

        // 異常 (未来時刻) または TTL 超過は進行中扱いしない
        if ( $age < 0 || $age >= self::RUN_STATE_TTL ) {
            wp_send_json_success( array( 'in_progress' => false ) );
        }

        // scheduler_run ロックが取られているかを補助情報として確認
        // (ロックが切れていても run_state が残っていれば in_progress とみなす方針)
        $lock_held = AIAP_Lock::is_held( AIAP_Lock_Keys::SCHEDULER_RUN );

        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'] : '',
        ) );
    }

    public static function ajax_run_now_start() {
        register_shutdown_function( array( __CLASS__, 'fatal_error_handler' ) );
        self::raise_long_running_limits();
        AIAP_Admin::ajax_verify();

        // Free プラン: 月10投稿の上限に達していたら拒否
        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(),
            ) );
        }

        // 並行性対策 (spec: 2026-05-15-concurrency-fixes):
        //
        // (a) 2 タブ Quick Post 検出: 同一ユーザーの 2 タブ同時起動で run_state が
        //     上書きされる問題を防ぐ。fresh な started_at が残っていれば拒否。
        $existing_state = self::get_run_state();
        if ( ! empty( $existing_state['started_at'] ) ) {
            $age = time() - (int) $existing_state['started_at'];
            if ( $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' ),
                ) );
            }
        }

        // (b) scheduler_run ロック取得: cron との並行を防ぎ RSS/queue の状態競合を抑止する。
        //     引数: (key, ttl_seconds, retries, sleep_microseconds)
        //     TTL=300s (cron と同じ), 10 回 × 1s = 最大 10 秒待機
        $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' ),
                ) );
            }
            // Bug fix: do_single_generate_and_check() は必ず wp_send_json_*() で終了する。
            // wp_send_json_*() -> wp_die() -> exit() のため、後段の try/finally の finally は
            // 実行されず (PHP 仕様: exit() は finally をスキップする)、旧実装ではロックが
            // 一度も解放されず TTL=300 秒のあいだ wp_options に残留していた。その結果、
            // 直後の手動投稿が「使用中」で弾かれていた。exit() でも確実に走る
            // register_shutdown_function でロックを解放する。
            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();
        // 手動投稿のアイキャッチ override (on/off) を effective 値に解決して
        // overrides['image_enabled_effective'] に積む。AI プロンプト側と image step の双方で同じ値を使う。
        $featured_image_mode = AIAP_Prompts::sanitize_image_mode( wp_unslash( $_POST['manual_featured_image_mode'] ?? 'on' ) );
        // 次回 Quick Post を開いた時の初期値復元のため user_meta に保存する
        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
        );

        // プロンプト欄の状態をそのまま user_meta に保存
        update_user_meta( get_current_user_id(), AIAP_Admin::USER_META_LAST_MANUAL_PROMPT, $prompt );

        // プロンプト必須化 (旧: 空欄→find_next_scheduled_task() 自動選択。仕様変更で廃止)
        if ( $prompt === '' ) {
            wp_send_json_error( array( 'message' => __( 'Prompt is required.', 'ai-auto-poster' ) ) );
        }

        // 選択タスク ID の妥当性チェック: 存在しないなら空に落として続行 (画面と裏側の整合防御)
        if ( $task_id !== '' ) {
            $sel_task = AIAP_Prompts::get_by_id( $task_id );
            if ( ! is_array( $sel_task ) ) {
                $task_id = '';
            }
        }

        // Bug fix (audit r9 self-audit, #39): 取りこぼしのキュー追加は「投稿後」に移動。
        // 旧実装はここ (投稿前) で enqueue_missed_for_manual を呼んでいたが、ユーザー仕様は
        // 「投稿しようとした際に、取りこぼしがあったら、今の投稿後を終えてから、取りこぼし
        // を処理する」。つまり現在分の投稿が確定してから取りこぼしをキューに積むのが正しい。
        // 失敗時は積まない (次回手動実行のタイミングで改めて積まれる)。
        // 実際の enqueue は ajax_run_now_step の post step 成功直後で行う (L4045 付近)。

        // ===== Quick Post の RSS フェーズ =====
        $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;
                }
            }
        }
        // 内部キー manual_rss_settings は run_state には引き継がない（不要）が、
        // overrides は run_state に保存されるためそのまま残ってもよい。
        // ===== /Quick Post の RSS フェーズ =====

        // オリジナルのプロンプトと上書きは run_state に保持して次リトライでも使う
        // featured_image_mode から計算した effective 値は $overrides['image_enabled_effective'] に
        // 含まれているため、state に別途持たせる必要はない。
        // 取りこぼしのキュー追加は finalize_run (全 step 完了時) で行うため、
        // state に recovered_count を持たせる必要はなくなった (#40)。
        // 二重投稿防止用 idempotency_key。
        // JS が submit 時に UUID を発行して送ってくる。一連の AJAX サイクル (start/retry/step)
        // を通じて同じ key が使われ、最終的に投稿 postmeta '_aiap_idempotency_key' に保存される。
        // post step で同じ key の既存投稿があれば、再作成せずそれを返す。
        // 主な保護対象:
        //   - 別メニューへ移動中にサーバ側生成が完走し、戻って再クリックした場合の二重投稿
        //   - ネットワーク再送による start AJAX の二度実行
        $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(),  // 2 タブ同時起動検出用 (spec: 2026-05-15-concurrency-fixes)
            'idempotency_key'   => $idempotency_key,  // 二重投稿防止用
        ) );

        // 1回だけAI生成+dedup判定
        // server_retry_count は JS 側で「サーバエラーで何回連続リトライしたか」を保持し、
        // 同じ start エンドポイントへ再投稿してくる(dedup ループ前段)ので、毎回読み取る。
        $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 );
        // ロック解放は release_scheduler_run_lock() (上で register_shutdown_function 登録済) が
        // 行う。do_single_generate_and_check() は exit() で終了するため finally は使えない。
    }

    /**
     * 手動実行のリトライ用Ajax
     * クライアントが retry_needed=true を受けたら再度このエンドポイントを叩く
     */
    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' ) ) );
        }

        // 並行性対策 (spec: 2026-05-15-concurrency-fixes):
        // retry も AI 再生成を含むため scheduler_run ロックを取得する。
        // busy 検出は不要（同ユーザーの既存進行中の retry 呼び出し）。
        $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' ),
                ) );
            }
            // Bug fix: do_single_generate_and_check() は wp_send_json_*() -> exit() で終了し、
            // 後段の try/finally の finally は実行されない。exit() でも走る
            // register_shutdown_function でロックを解放する (start と同じ対策)。
            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: JS が「これまでサーバエラーで何回リトライしたか」を保持して送ってくる。
            // 0=初回、1=1 回目のリトライ実行中、… のサイクル番号。PHP は受け取ったらログのラベルにのみ使う。
            $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 );
        // ロック解放は release_scheduler_run_lock() (register_shutdown_function 登録済) が行う。
    }

    /**
     * 1回だけAI生成 + dedup判定 を実行し、結果をJSONで返す
     * リトライが必要なら retry_needed=true で返す(クライアントが再度Ajaxを投げる)
     */
    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 ) );

        // AI生成(1回だけ)。サーバエラーリトライは JS 側で別 AJAX として呼び直すことで実現する
        // (画面に「サーバ障害対応中 N/M」進捗を表示するため)。
        // 永続エラー(課金枯渇/認証/入力不正)は retryable=false で即終了させる。
        // 一時エラー(レート制限/サーバ一時障害)は retryable=true で JS にリトライさせる。
        // is_manual=true: 自動cronがセットする現在日時overrideを参照しない
        // (手動実行は投稿し損ね処理の対象ではないため、cronと並走時の混入を防ぐ)
        $result = AIAP_AI_Client::generate( $current_prompt, $overrides, true );

        if ( is_wp_error( $result ) ) {
            // エラー種別を判定して、JS にリトライ可否を伝える
            $err_data    = $result->get_error_data();
            $is_retryable = is_array( $err_data ) && array_key_exists( 'retryable', $err_data )
                ? (bool) $err_data['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;
            }

            // 生のレスポンス本文(raw)はログに含めない: AI 出力にユーザー入力や潜在的な機密が
            // 紛れ込むリスクがあるため、format_error_for_log でヒント+API エラー本文のみ記録する。
            //
            // Bug fix (audit r9 self-audit, #36): リトライ中の中間ログのレベルを
            // 'warning' に統一する。旧実装は admin.php (UI) で 'error'、poster.php (cron) で
            // 'info'、image-client.php (画像) で 'info' と経路ごとにバラバラだった。
            // リトライ中の途中失敗は「まだ復旧の見込みあり」なので 'info' は弱すぎ、
            // 一方 'error' は最終失敗 (log_server_error_failed) と紛らわしい。'warning' が妥当。
            $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 ) {
                // 永続エラー or 上限到達: 即終了。run_state もクリア。
                self::clear_run_state();
                wp_send_json_error( array(
                    'step'      => 'ai',
                    'message'   => $result->get_error_message(),
                    'retryable' => false,
                    'kind'      => $kind,
                ) );
            }

            // 一時エラー: run_state は維持して JS にリトライ判断を委ねる
            // Bug fix (audit r9 self-audit, #34/#35): バックオフ秒数を PHP 側で計算して渡す。
            // 手動実行のリトライは「PHP 側で sleep + 4 回試行」ではなく「JS が retryable=true
            // を受けて別 AJAX で投げ直す」設計のため、PHP 内 sleep が効かない経路。
            // ここでは AI_Client::compute_retry_sleep_seconds() (PHP・JS 共通の真実の供給源)
            // で次回までの待機秒数を計算し、JS には next_sleep_seconds として渡す。
            // JS は受け取った値をそのまま setTimeout に渡すだけで、独自にバックオフ計算しない。
            $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,
                'next_sleep_seconds' => $next_sleep,
            ) );
        }

        // dedup無効 なら即通過
        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( array(
                'retry_needed'   => false,
                'next_step'      => 'post',
                'message'        => __( 'Creating post…', 'ai-auto-poster' ),
                'dedup_attempts' => $dedup_attempts,
            ) );
        }

        // 類似チェック
        $dedup_result = AIAP_Dedup::check( $result['title'], $result['content'] );

        // 非類似 → そのまま採用
        if ( ! $dedup_result['is_similar'] ) {
            if ( $attempt > 1 ) {
                AIAP_Logger::add( 'info', 'log_dedup_passed', array(
                    'src_manual', $attempt, $dedup_result['method'],
                ) );
            }
            self::set_run_state( array_merge( $state, array(
                'result'   => $result,
                'prompt'   => $original_prompt,
                'post_id'  => 0,
                'warnings' => array(),
            ) ) );
            wp_send_json_success( array(
                'retry_needed'   => false,
                'next_step'      => 'post',
                'message'        => __( 'Creating post…', 'ai-auto-poster' ),
                'dedup_attempts' => $dedup_attempts,
            ) );
        }

        // 類似 かつ リトライ上限 → 諦めて採用(最後の生成を投稿)
        // max_retry は「リトライ最大回数」なので、試行回数が max_retry を超えたら打ち切る(最大 max_retry+1 回試行)。
        if ( $attempt > $max_retry ) {
            AIAP_Logger::add( 'info', 'log_dedup_max_reached', array( 'src_manual', $max_retry ) );
            $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( array(
                'retry_needed'   => false,
                'next_step'      => 'post',
                'message'        => __( 'Creating post…', 'ai-auto-poster' ),
                'dedup_attempts' => $dedup_attempts,
            ) );
        }

        // 類似 → クライアントにリトライ要求
        $dedup_attempts++;
        AIAP_Logger::add( 'info', 'log_dedup_retry', array(
            'src_manual', $attempt, $max_retry,
            $dedup_result['method'],
            mb_substr( $dedup_result['reason'], 0, 80 ),
        ) );

        $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',  // JS が「サーバエラーリトライ」と区別するため
            'next_attempt'   => $attempt + 1,
            'retry_prompt'   => $retry_prompt,
            'dedup_attempts' => $dedup_attempts,
            'reason'         => $dedup_result['reason'],
        ) );
    }

    public static function ajax_run_now_step() {
        // Bug fix (audit r9 self-audit): post / image / seo の各 step 実行中に
        // PHP fatal が起きた場合の防御。旧実装は ajax_run_now_start と _retry にしか
        // 登録していなかったため、step ハンドラでクラッシュすると:
        //   - ログに何も残らない
        //   - run_state が transient に残ったまま (clear_run_state されない)
        //   - クライアントは応答無しのままタイムアウト or fetch reject になる
        // start / retry と同じ shutdown フックを step にも入れる。
        register_shutdown_function( array( __CLASS__, 'fatal_error_handler' ) );
        // Bug fix (audit r9 self-audit): 画像 step は call_with_retry で最大 4 回試行 +
        // sleep(1+2+3) するため、PHP の max_execution_time に到達して途中で殺される
        // ケースが報告されている (例: 1/3 のログだけ残って 2/3 と 3/3 が失われる)。
        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':
                $result = $state['result'];
                $ai     = AIAP_AI_Client::get_settings();

                // 二重投稿防止: 同じ idempotency_key で既に投稿が作成済みなら、その投稿 ID を返す。
                // 想定シナリオ:
                //   - ユーザーが「今すぐ実行」中に別タブへ移動、サーバ側は ignore_user_abort で完走
                //   - ユーザーが戻ってきて再クリック、step AJAX が再度走る
                // この時、AI 結果が同じ state に残っていると post step も走って 2 本目が作られる。
                // idempotency_key を postmeta に保存しておけば、検索で発見して再利用できる。
                $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 はそのまま (image / seo step が継続する)。
                        $state['post_id'] = $existing_pid;
                        self::set_run_state( $state );
                        wp_send_json_success( array(
                            'step'      => 'post',
                            'post_id'   => $existing_pid,
                            'idempotent' => true,
                            'next_step' => 'image',
                            'message'   => __( 'Post was already created (idempotent reuse).', 'ai-auto-poster' ),
                        ) );
                    }
                }

                // 共通の投稿作成処理に委譲(自動経路の AIAP_Poster::run_single と同じ phase_create_post を呼ぶ)
                // 経路固有postmeta: _aiap_manual=1 (手動投稿の識別)
                //                   _aiap_task_id=<id> (空欄→次タスク補充時のみ)
                //                   _aiap_idempotency_key=<key> (二重投稿防止用)
                $extra_meta = array( '_aiap_manual' => 1 );
                if ( ! empty( $state['task_id'] ) ) {
                    $extra_meta['_aiap_task_id'] = $state['task_id'];
                }
                if ( $idempotency_key !== '' ) {
                    $extra_meta['_aiap_idempotency_key'] = $idempotency_key;
                }

                // RSS 消費記事: pick モードは AI 応答の selected_rss_index で 1 件確定、digest は全件
                // (この計算ロジックは AIAP_Poster::run_single 内と完全に同じ。共通化の余地はあるが、
                //  phase_create_post の引数として $rss_consumed を渡す責務は呼び出し側に置く方針。)
                $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_mode     = isset( $rss_settings['rss_mode'] ) ? (string) $rss_settings['rss_mode'] : 'pick';
                $rss_consumed = array();
                if ( ! empty( $rss_used ) ) {
                    if ( $rss_mode === 'pick' ) {
                        $rss_idx = isset( $result['selected_rss_index'] ) ? (int) $result['selected_rss_index'] : 0;
                        if ( $rss_idx < 1 || $rss_idx > count( $rss_used ) ) {
                            AIAP_Logger::add( 'warning', 'log_rss_pick_index_invalid', array( 'src_manual', $rss_idx, count( $rss_used ) ) );
                            $rss_idx = 1;
                        }
                        $rss_consumed = array( $rss_used[ $rss_idx - 1 ] );
                    } else {
                        $rss_consumed = $rss_used;
                    }
                }

                // task 配列を再構築 (= phase_create_post に渡す)
                // - クイック投稿モード (task_id 空) → id は空文字 (= increment_used は phase 内でスキップ)
                // - 次タスク補充モード (task_id あり) → id にそれをセット
                $task_for_phase = array(
                    'id'     => isset( $state['task_id'] ) ? (string) $state['task_id'] : '',
                    'prompt' => isset( $state['original_prompt'] ) ? (string) $state['original_prompt'] : '',
                );

                // Phase: 投稿作成 (= create_post_from_result + RSS extra_meta + increment_used + RSS mark_used)
                // 完了ログ (log_manual_post_created) は出さない: 仕様変更により seo step の phase_finalize に統合 (= 自動経路と同じ順序「投稿→画像→SEO→完了ログ」)
                $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: 画像 / SEO step で参照する情報を保存
                // (rss_consumed と rss_settings も保存して seo step の phase_finalize で再利用する)
                $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 );

                // 取りこぼしのキュー追加は、画像 / SEO まで含めた全 step 完了後 (finalize_run 内)
                // で行う。仕様「今の投稿後を終えてから、取りこぼしを処理する」の「投稿後」は
                // 投稿エンティティ作成直後ではなく、画像 + SEO まで含めた完全な処理完了を指す。
                // (#39 で post step に置いていたが、ユーザー指摘で #40 で finalize_run に移動)

                // SEO の有効判定は AIAP_SEO::apply() 側に委譲(常に seo step へ進む)。
                // image step は手動側の featured_image_mode が effective OFF の場合に飛ばす。
                // (フロント UI に「アイキャッチを生成しています…」の偽完了表示が出るのを防ぐ)
                // effective 値は ajax_run_now_start で計算済みのものを overrides 経由で読む。
                if ( ! empty( $state['overrides']['image_enabled_effective'] ) ) {
                    wp_send_json_success( array( 'next_step' => 'image', 'message' => __( 'Generating featured image…', 'ai-auto-poster' ), 'post_id' => $pid ) );
                }
                wp_send_json_success( array( 'next_step' => 'seo', 'message' => __( 'Applying SEO settings…', 'ai-auto-poster' ), 'post_id' => $pid ) );
                break; // Bug fix (audit r9): wp_send_json_* は exit するので到達しないが、防御的に break を置く

            case 'image':
                // Bug fix (audit r9): クライアントが順序を破って image step を直接叩いた場合、
                // post step で詰めるはずの post_id / slug が state に無い可能性がある。
                // undefined index 経由のエラーで JSON レスポンスが壊れる前にここで弾く。
                if ( empty( $state['post_id'] ) ) {
                    wp_send_json_error( array( 'message' => __( 'Out-of-order step. Please retry.', 'ai-auto-poster' ) ) );
                }
                $pid    = (int) $state['post_id'];
                $result = $state['result'];
                $slug   = isset( $state['slug'] ) ? (string) $state['slug'] : '';
                // result に slug を補完 (= phase_attach_image の引数で参照される)
                if ( ! isset( $result['slug'] ) || $result['slug'] === '' ) {
                    $result['slug'] = $slug;
                }
                // task 配列を再構築 (= phase_attach_image の引数 user_prompt_full は task['prompt'])
                $task_for_phase = array(
                    'id'     => isset( $state['task_id'] ) ? (string) $state['task_id'] : '',
                    'prompt' => isset( $state['original_prompt'] ) ? (string) $state['original_prompt'] : '',
                );
                // effective 値は ajax_run_now_start で計算済みのものを overrides 経由で読む。
                $effective_on = ! empty( $state['overrides']['image_enabled_effective'] ) ? 1 : 0;

                // Phase: アイキャッチ画像の生成・添付 (= 自動経路 run_single と同じ phase_attach_image を呼ぶ)
                $r = AIAP_Poster::phase_attach_image( $pid, $result, $task_for_phase, $effective_on, 'src_manual' );
                if ( is_wp_error( $r ) ) {
                    // warnings は UI 表示用なので短いヒントのみ。詳細は実行ログで確認できる (= phase_attach_image 内で log_thumbnail_failed 済み)。
                    /* translators: %s: underlying error message from the image provider */
                    $state['warnings'][] = sprintf( __( 'Featured image generation failed, but the post was created (%s)', 'ai-auto-poster' ), $r->get_error_message() );
                    self::set_run_state( $state );
                }
                wp_send_json_success( array(
                    'next_step' => 'seo',
                    'message'   => __( 'Applying SEO settings…', 'ai-auto-poster' ),
                    'warnings'  => $state['warnings'],
                ) );
                break; // Bug fix (audit r9): 同上、防御的 break

            case 'seo':
                // Bug fix (audit r9): post step を経ずに seo を叩かれたケースの防御
                if ( empty( $state['post_id'] ) ) {
                    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();

                // Phase: SEO 適用 + 完了ログ (= 自動経路 run_single と同じ phase_finalize を呼ぶ)
                // source='src_manual' により完了ログは log_manual_post_created / _with_rss が選ばれる (UI 表示形式維持)
                // post step に置いていた完了ログはここに統合された (= 自動と同じ処理順序「投稿→画像→SEO→完了ログ」)
                $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; // Bug fix (audit r9): 同上、防御的 break

            default:
                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();
        }

        // Bug fix (audit r9 self-audit, #40): 取りこぼしのキュー追加は seo step 完了の
        // この時点で行う。仕様「投稿しようとした際に、取りこぼしがあったら、今の投稿後を
        // 終えてから、取りこぼしを処理する」の「投稿後」は post step (投稿エンティティ
        // 作成) ではなく、image + seo を含む全 step が完了した時点。失敗時はここに到達
        // しないため、取りこぼしは積まれない (次回手動実行で改めて積まれる)。設定 OFF・
        // Free 上限到達時は enqueue_missed_for_manual 内で 0 が返る。
        $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,
        );
    }
}
