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

/**
 * WP-Cronスケジューラ
 *
 * 5分間隔でチェックし、各タスクのスケジュール（曜日×時刻）に該当するタスクを実行。
 */
class AIAP_Scheduler {

    const OPTION_LAST_RUN          = 'aiap_last_run';
    const OPTION_OVERRIDE_POSTDATE = 'aiap_override_postdate'; // バックデート用
    const OPTION_RUN_QUEUE         = 'aiap_run_queue';         // 実行待ちキュー(取りこぼし含む)
    const OPTION_INITIAL_MISSED_SCAN_DONE = 'aiap_initial_missed_scan_done'; // 初回インストール後の過去未投稿確認スキップ済みフラグ
    // #47: ランダム投稿モードのその日の時刻キャッシュ。形式:
    //   array( 'task_id' => array( 'date_str' => 'YYYY-MM-DD', 'times' => array('HH:MM', ...) ) )
    // 日付が変わると古いエントリは生成タイミングで上書きされる。
    const OPTION_RANDOM_TIMES      = 'aiap_random_times';
    const CHECK_INTERVAL           = 5;
    const RECOVER_DAYS             = 7; // 取りこぼし救済対象の日数 (過去N日分まで遡る)

    public static function init() {
        add_filter( 'cron_schedules', array( __CLASS__, 'add_check_schedule' ) );
        add_action( AIAP_CRON_HOOK, array( __CLASS__, 'run' ) );
        // セーフティネットの再登録は admin と cron 経路でのみ走らせる。
        // フロントの全リクエストでは DB クエリ(wp_next_scheduled)が走らないようにする。
        add_action( 'admin_init', array( __CLASS__, 'ensure_scheduled' ) );
        add_action( AIAP_CRON_HOOK, array( __CLASS__, 'ensure_scheduled' ), 1 );
    }

    /**
     * 自動投稿が有効なのにcronイベント未登録なら登録する（セーフティネット）
     *
     * フロントエンドの全リクエストで動かすと毎回 DB クエリが追加されるため、
     * 管理画面 (admin_init) と cron 自身の発火時のみ走らせる。
     * ユーザーが管理画面に1回でもアクセスすれば再登録される。
     *
     * 注意: ここで AIAP_AI_Client::get_settings() を呼ぶと全リクエストごとに
     * APIキー7社分の復号(sha256+openssl_decrypt)が発生する。
     * ここでは enabled フラグだけ確認できればよいので、生のオプションを直接読む。
     */
    public static function ensure_scheduled() {
        $raw = get_option( AIAP_AI_Client::OPTION_KEY, array() );
        if ( ! is_array( $raw ) || empty( $raw['enabled'] ) ) {
            return;
        }
        if ( ! wp_next_scheduled( AIAP_CRON_HOOK ) ) {
            wp_schedule_event( time() + 30, 'aiap_five_minutes', AIAP_CRON_HOOK );
        }
    }

    public static function add_check_schedule( $schedules ) {
        // 他プラグインや異常状態で $schedules が配列でない場合の防御
        // (PHP 8 では配列以外への代入で Fatal が発生するため)
        if ( ! is_array( $schedules ) ) {
            $schedules = array();
        }
        $schedules['aiap_five_minutes'] = array(
            'interval' => self::CHECK_INTERVAL * MINUTE_IN_SECONDS,
            'display'  => __( 'AI Auto Poster (every 5 minutes)', 'ai-auto-poster' ),
        );
        return $schedules;
    }

    /**
     * cron発火時の処理(キュー方式)
     *
     * 動作:
     * 1. 当日の未実行タスクを収集してキューに追加(重複除外)
     * 2. キューから先頭1件だけ実行(古い予定時刻のものから処理)
     * 3. 投稿の post_date は本来の予定時刻にバックデート(設定 ON 時)
     * 4. 取りこぼし復元: 「新しい時刻側からN件まで」をキュー対象とする(古いほうは捨てる)
     * 5. 復元 OFF の場合: 過去の取りこぼしは無視、現在の窓(now-CHECK_INTERVAL <= 予定 <= now)内のみ処理
     */
    public static function run() {
        $settings = AIAP_AI_Client::get_settings();
        if ( empty( $settings['enabled'] ) ) {
            return;
        }

        // Bug fix (audit r9 self-audit): cron 経由の AI 生成 + 画像 retry は
        // 4 回試行 × 複数タスクを 1 リクエスト内で処理するため、デフォルトの
        // max_execution_time (30〜60 秒) では確実に途中で殺される。AJAX 側と
        // 同じ理由でリトライが完走しない症状が出るため、cron 経路でも実行制限を
        // 緩める。set_time_limit が disable_functions に入っている共用ホスト
        // でも warning を出さないよう @ で握る。
        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' );
        }

        // 並列実行ロック: WP-Cron が高頻度で発火した場合や手動 cron との競合で
        // 同じ run_key の記事を二重生成しないよう、5分のロックを取得する。
        // 取得できなければ別プロセスが処理中とみなして即座に return する。
        // Bug fix (audit r9): acquire はトークン文字列を返す。release で渡してレース時の
        // 他者ロック誤削除を防ぐ。これは特に重要で、scheduler が長時間ハングして TTL 経過後に
        // 別 cron が強制奪取 → 元プロセスが復活して release を呼んだ場合、新ロックが
        // 誤って削除されて二重実行になるという、本クラスにとって最悪のレースを塞ぐ。
        if ( ! class_exists( 'AIAP_Lock' ) ) {
            return;
        }
        $lock_token = AIAP_Lock::acquire( 'scheduler_run', 300 );
        if ( $lock_token === false ) {
            return;
        }

        // try/finally で必ずロックを解放する(致命的エラーが起きても解放)
        try {
            self::run_locked( $settings );
        } finally {
            AIAP_Lock::release( 'scheduler_run', $lock_token );
        }
    }

    /**
     * ロック取得済みの状態で実行する run() の本体
     *
     * 直接呼んではいけない。常に self::run() 経由で呼ぶこと。
     */
    private static function run_locked( $settings ) {
        // Free プラン: 月10投稿の上限に達していたら以降の処理を中止
        if ( ! AIAP_License::can_post_more() ) {
            AIAP_Logger::add( 'info', 'log_free_limit_reached_skip_cron', array() );
            return;
        }

        $today_key = AIAP_Prompts::get_today_key();
        $now_hi    = current_time( 'H:i' );
        $today_str = current_time( 'Y-m-d' );
        $last_run  = get_option( self::OPTION_LAST_RUN, array() );

        $recover_enabled = ! empty( $settings['cron_recover_missed'] );
        $recover_max     = self::effective_recover_max( $settings );

        $tasks = AIAP_Prompts::get_all();

        // ========== Phase 1: 投稿対象を決める ==========
        // 仕様 (audit r9 #41): トリガーがなんであっても、投稿しようとした際に取りこぼしが
        // あったら、今の投稿後を終えてから取りこぼしを処理する (キューに積む。実行は次以降の
        // cron が消化)。手動経路 (#39 #40) と論理を揃えるため、自動 cron もこの順序に変更。
        //
        // 旧実装は「取りこぼし + 現在窓内 をまとめてキューに積む → 先頭取り出して実行」だが、
        // それだと「投稿前に取りこぼしを積む」になり手動経路と非対称。新ロジック:
        //   a) キューに既存の取りこぼし (過去の cron で積まれたもの) があれば先頭を取る
        //   b) なければ当日窓内 [now-CHECK_INTERVAL, now] のスケジュールから取る
        //   c) どちらもなければ何もしない (= 投稿しないので取りこぼし収集も走らない)

        $head = null;

        // a) キューを掃除して先頭を確認 (purge_and_add_to_queue は candidates=array() で
        //    「掃除のみ」を実行できる。期限切れ・未来日・実行済みを除外する)
        $queue_result = self::purge_and_add_to_queue( array(), $today_str, $last_run );
        $queue        = $queue_result['queue'];

        if ( ! empty( $queue ) ) {
            $head = array_shift( $queue );
        }
        // Bug fix (audit r9 self-audit, #43): キューが空でも掃除済みの状態を必ず保存する。
        // 以前は if 分岐内でしか set_queue を呼んでおらず、Phase 1 でキュー空 + 当日窓内空で
        // early return するケースで DB に掃除前のゴミが残っていた。次の cron で再度掃除される
        // ので致命的ではなかったが、不整合を最小化するため常に保存する。
        self::set_queue( $queue );

        if ( $head === null ) {
            // b) 当日窓内のスケジュールから 1 件 (古い順)
            $now_min     = self::time_to_minutes( $now_hi );
            $window_min  = $now_min - self::CHECK_INTERVAL;
            $time_filter = function ( $time ) use ( $now_min, $window_min ) {
                $sched_min = self::time_to_minutes( $time );
                return $sched_min >= $window_min && $sched_min <= $now_min;
            };
            $current_window = self::collect_runs_for_day(
                $tasks, $today_str, $today_key, $last_run, $now_hi, $time_filter, array()
            );

            if ( ! empty( $current_window ) ) {
                usort( $current_window, function ( $a, $b ) {
                    return strcmp( $a['time'], $b['time'] );
                } );
                $head = $current_window[0];
            }
        }

        // c) 投稿対象がない場合でも取りこぼし収集は走らせる。
        //
        // 旧実装は「投稿対象なし → return」で取りこぼし収集もスキップしていたが、これにより
        // WP-cron の発火タイミングが CHECK_INTERVAL(5分) を逃した場合、過去未実行の予定が
        // last_run にもキューにもマークされず永久に放置される現象が発生する。
        //
        // 再現シナリオ:
        //   18:00 の予定 → WP-cron が 18:06 に発火 → 当日窓 [18:01,18:06] に予定なし
        //   → Phase 1 (c) で早期 return → enqueue_missed_after_post 未実行
        //   → 18:00 の予定は永遠に「実行されないが取りこぼしカウントにも入らない」状態
        //
        // ダッシュボード上は「自動 N/M (M-N=未消化)」「取りこぼし 0」「次の投稿予定なし」が
        // 同居する不可解な状態として現れていた。投稿成功時の Phase 4 経路に依存せず、
        // 空振り cron でも取りこぼしを回収できるよう、ここでも収集を実行する。
        //
        // 副作用は限定的: $missed が空なら enqueue_missed_after_post は早期 return するため
        // ほとんどの空振り cron では追加の処理は走らない。
        if ( $head === null ) {
            self::enqueue_missed_after_post( 'auto' );
            return;
        }

        // ========== Phase 2: 投稿実行 ==========

        // 念のため、未来日時のキューは即時実行しない。
        // 旧版で未来キューが残っていた場合に、今日の未到来時刻で予約投稿される事故を防ぐ。
        if ( self::is_future_schedule( $head['date_str'] ?? '', $head['time'] ?? '' ) ) {
            return;
        }

        $task = AIAP_Prompts::get_by_id( $head['task_id'] );
        if ( ! $task ) {
            // タスクが削除されている → スキップ
            AIAP_Logger::add( 'info', 'log_auto_queue_task_missing', array( $head['task_id'], $head['time'] ));
            return;
        }

        if ( self::is_run_completed( $head['run_key'], $last_run, $task['id'], $head['date_str'], $head['time'] ) ) {
            $last_run[ $head['run_key'] ] = current_time( 'mysql' );
            update_option( self::OPTION_LAST_RUN, $last_run, false );
            return;
        }

        $task_label = AIAP_Prompts::format_log_label( $task );
        // date_str + time から実際の予定時刻を計算し、now との差分(分)を求める。
        // strtotime() は PHP デフォルト TZ を使うが、date_str/time は WP の current_time()
        // で生成されたサイト TZ の値なので、wp_timezone() を使って TZ を揃える。
        $scheduled_ts = 0;
        try {
            $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( date_default_timezone_get() );
            $dt = new DateTimeImmutable( $head['date_str'] . ' ' . $head['time'] . ':00', $tz );
            $scheduled_ts = $dt->getTimestamp();
        } catch ( Exception $e ) {
            // パース失敗時は差分計算をスキップ
            $scheduled_ts = 0;
        }
        $now_ts   = time();
        $diff_min = $scheduled_ts ? floor( ( $now_ts - $scheduled_ts ) / 60 ) : 0;
        $is_late  = $diff_min >= self::CHECK_INTERVAL;

        // ログの「残りキュー件数」は現時点の値 (この投稿実行後に Phase 4 で再追加されるが、
        // 「この投稿開始時点」の残数を見せる方が直感的)。
        $remaining_queue_count = count( self::get_queue() );

        if ( $is_late ) {
            AIAP_Logger::add( 'info', 'log_auto_recovered_late', array(
                $diff_min, $head['date_str'], $head['time'], $task_label, $remaining_queue_count,
            ));
        } else {
            AIAP_Logger::add( 'info', 'log_auto_run_started', array(
                $head['date_str'], $head['time'], $task_label, $remaining_queue_count,
            ));
        }

        // post_date バックデート用の一時情報
        update_option( self::OPTION_OVERRIDE_POSTDATE, array(
            'date_str' => $head['date_str'],
            'time_str' => $head['time'],
            'task_id'  => $task['id'],
            'run_key'  => $head['run_key'],
            'set_at'   => time(),
        ), false );

        $post_id = AIAP_Poster::run_single( $task, false );

        delete_option( self::OPTION_OVERRIDE_POSTDATE );

        if ( is_wp_error( $post_id ) ) {
            // 失敗時は実行済みにしない。次回 cron で再評価させる。
            // 取りこぼし収集も走らない (仕様: 投稿成功時のみキュー追加)。
            return;
        }

        // ========== Phase 3: 投稿成功 → last_run マーク ==========
        $last_run[ $head['run_key'] ] = current_time( 'mysql' );
        update_option( self::OPTION_LAST_RUN, $last_run, false );

        // ========== Phase 4: 投稿後に取りこぼしを集めてキューに追加 ==========
        // 仕様 (#41 #42): 投稿が成功した今、新しい取りこぼしを集めてキューに積む。
        // 手動経路 (finalize_run) と完全に同じロジックなので共通関数 enqueue_missed_after_post
        // に集約 (#42)。$source 引数でログキー (log_auto_* / log_manual_*) を切り替えるだけ。
        self::enqueue_missed_after_post( 'auto' );

        // 投稿成功後 / Phase 4 完了後に最新の last_run を再取得 (Phase 4 内で更新されるため)
        $last_run = get_option( self::OPTION_LAST_RUN, array() );

        // ========== Phase 5: 古い last_run を掃除 + 保存 ==========
        // 古い last_run を掃除 (RECOVER_DAYS + 1 日以上前)
        // 救済対象期間中のキュー重複防止に必要なため、RECOVER_DAYS より長く保持する
        // date_create は無効値時に false を返す。チェーンで Fatal にならないようガードし、
        // 失敗時は strtotime ベースのフォールバックで安全に算出する。
        $cutoff_str = current_time( 'Y-m-d', false );
        $cutoff_dt  = date_create( $cutoff_str );
        if ( $cutoff_dt instanceof DateTime ) {
            $cutoff = $cutoff_dt->modify( '-' . ( self::RECOVER_DAYS + 1 ) . ' days' )->format( 'Y-m-d' );
        } else {
            // 例外的なフォールバック (current_time が空文字を返す等)
            $cutoff = wp_date( 'Y-m-d', strtotime( '-' . ( self::RECOVER_DAYS + 1 ) . ' days', time() ) );
        }
        foreach ( $last_run as $k => $v ) {
            if ( substr( $k, 0, 10 ) < $cutoff ) {
                unset( $last_run[ $k ] );
            }
        }
        update_option( self::OPTION_LAST_RUN, $last_run, false );
    }

    /**
     * 実行待ちキューを取得
     */
    private static function get_queue() {
        $q = get_option( self::OPTION_RUN_QUEUE, array() );
        return is_array( $q ) ? $q : array();
    }

    /**
     * ダッシュボード向けの公開ヘルパー (Phase 1).
     * 取りこぼし(キュー)の件数を返す。
     */
    public static function get_queue_count() {
        return count( self::get_queue() );
    }

    /**
     * ダッシュボード向けの公開ヘルパー (Phase 1).
     * 次の投稿予定 (= 今日の未経過分 + 翌日以降の最初) を 1 件返す。
     * 形式: array( 'time' => 'HH:MM', 'date' => 'Y-m-d', 'task_id' => int, 'is_today' => bool )
     * 何もなければ null。
     *
     * 注: collect_runs_for_day() は取りこぼし回収用で「過去の未実行スケジュール」を集めるため
     * 別ロジックで実装する。
     */
    public static function get_next_scheduled_run() {
        $tasks = AIAP_Prompts::get_all();
        if ( empty( $tasks ) ) return null;

        $tz        = wp_timezone();
        $now       = new DateTimeImmutable( 'now', $tz );
        $now_hi    = $now->format( 'H:i' );
        $today     = $now->format( 'Y-m-d' );
        $today_key = AIAP_Prompts::day_number_to_key( (int) $now->format( 'w' ) );

        // ヘルパー: 1 タスク + 1 曜日キー → 時刻配列 (HH:MM, ゼロ埋め済み)
        $extract_times = function( $task, $day_key, $is_today ) {
            $times = array();
            if ( AIAP_Prompts::is_random_mode_active( $task ) ) {
                // ランダムモードは今日の生成キャッシュのみ。翌日以降は予測不可。
                if ( ! $is_today ) return array();
                $times = self::get_random_times_for_today( $task );
            } else {
                $sched = isset( $task['schedule'] ) && is_array( $task['schedule'] ) ? $task['schedule'] : array();
                $times = isset( $sched[ $day_key ] ) && is_array( $sched[ $day_key ] ) ? $sched[ $day_key ] : array();
            }
            $clean = array();
            foreach ( $times as $t ) {
                $t = trim( (string) $t );
                if ( $t === '' || ! preg_match( '/^\d{1,2}:\d{2}$/', $t ) ) continue;
                list( $h, $m ) = explode( ':', $t );
                $clean[] = sprintf( '%02d:%02d', (int) $h, (int) $m );
            }
            return $clean;
        };

        // 今日分の候補を集める:
        //   - 未来時刻 ($time > $now_hi)
        //   - 過去時刻でも is_run_completed が false なもの (= 未実行で last_run / 投稿メタともに無し)
        //     → 取りこぼし扱いで、次の cron 実行で消化されるべきもの
        // 過去未実行を優先表示する (時刻順で最古が真っ先に実行されるため)。
        $last_run = get_option( self::OPTION_LAST_RUN, array() );
        if ( ! is_array( $last_run ) ) { $last_run = array(); }

        $today_past_pending = array();
        $today_future       = array();
        foreach ( $tasks as $task ) {
            if ( empty( $task['enabled'] ) ) continue;
            // task_id は uniqid('task_', true) 由来の文字列。(int) キャストすると 0 になり
            // run_key の合成で last_run / 投稿メタと絶対にマッチしなくなるため、必ず文字列のまま使う。
            $tid = isset( $task['id'] ) ? (string) $task['id'] : '';
            if ( $tid === '' ) { continue; }
            foreach ( $extract_times( $task, $today_key, true ) as $time ) {
                if ( $time > $now_hi ) {
                    $today_future[] = array(
                        'time'    => $time,
                        'date'    => $today,
                        'task_id' => $tid,
                    );
                } else {
                    $run_key = $today . '_' . $tid . '_' . $time;
                    if ( ! self::is_run_completed( $run_key, $last_run, $tid, $today, $time ) ) {
                        $today_past_pending[] = array(
                            'time'    => $time,
                            'date'    => $today,
                            'task_id' => $tid,
                        );
                    }
                }
            }
        }
        // 優先順位: 過去未実行 (最古) → 今日未来 (最早)
        if ( ! empty( $today_past_pending ) ) {
            usort( $today_past_pending, function( $a, $b ) { return strcmp( $a['time'], $b['time'] ); } );
            $first = $today_past_pending[0];
            $first['is_today'] = true;
            return $first;
        }
        if ( ! empty( $today_future ) ) {
            usort( $today_future, function( $a, $b ) { return strcmp( $a['time'], $b['time'] ); } );
            $first = $today_future[0];
            $first['is_today'] = true;
            return $first;
        }

        // 翌日以降を 7 日先まで
        for ( $i = 1; $i <= 7; $i++ ) {
            $d  = $now->modify( "+{$i} day" );
            $ds = $d->format( 'Y-m-d' );
            $dk = AIAP_Prompts::day_number_to_key( (int) $d->format( 'w' ) );
            $day_candidates = array();
            foreach ( $tasks as $task ) {
                if ( empty( $task['enabled'] ) ) continue;
                foreach ( $extract_times( $task, $dk, false ) as $time ) {
                    $day_candidates[] = array(
                        'time'    => $time,
                        'date'    => $ds,
                        // task_id は uniqid 文字列。(int) キャストすると 0 になるので文字列で保持。
                        'task_id' => isset( $task['id'] ) ? (string) $task['id'] : '',
                    );
                }
            }
            if ( ! empty( $day_candidates ) ) {
                usort( $day_candidates, function( $a, $b ) { return strcmp( $a['time'], $b['time'] ); } );
                $first = $day_candidates[0];
                $first['is_today'] = false;
                return $first;
            }
        }
        return null;
    }

    /**
     * 実行待ちキューを保存
     */
    private static function set_queue( array $queue ) {
        update_option( self::OPTION_RUN_QUEUE, $queue, false );
    }

    /**
     * 投稿成功後に「取りこぼしの予定」をキューに追加する共通処理。
     *
     * 自動 cron / 手動「今すぐ実行」の両経路から呼ばれる。仕様 (audit r9 #42):
     * 「トリガーがなんであっても、投稿しようとした際に取りこぼしがあったら、今の投稿後を
     * 終えてから取りこぼしを処理する」を 1 箇所で実装する (旧実装は両経路で 30 行同等の
     * ロジックを別々に書いていた DRY 違反)。
     *
     * - settings.enabled / Free 上限到達時は 0 件で早期 return
     * - cron_recover_missed が ON: 当日窓外 + 過去7日の未実行を最大 cron_recover_max 件
     *                              キューに追加
     * - cron_recover_missed が OFF: 当日窓外の未実行を「実行済み」として埋葬
     *                                (= last_run マーク + ログ。次回以降取りこぼしとして
     *                                  拾わない)
     * - initial install 直後: 過去スケジュールは取りこぼし扱いせず実行済みマーク
     *
     * ログキーは {$source}_ プレフィックスで分岐:
     *   log_auto_dropped_old / log_manual_dropped_old
     *   log_auto_recover_off / log_manual_recover_off
     *   log_auto_enqueued / log_manual_enqueued
     *
     * @param string $source 'auto' (cron 経路) or 'manual' (手動 UI 経路)
     * @return int キューに新規追加された件数
     */
    public static function enqueue_missed_after_post( $source ) {
        $source = ( $source === 'auto' ) ? 'auto' : 'manual';

        $settings = AIAP_AI_Client::get_settings();
        if ( empty( $settings['enabled'] ) ) { return 0; }

        // Free プラン月間上限到達時はキューに積んでも次回 cron で消化されないため早期 return。
        if ( class_exists( 'AIAP_License' ) && ! AIAP_License::can_post_more() ) {
            return 0;
        }

        $recover_enabled = ! empty( $settings['cron_recover_missed'] );
        $recover_max     = self::effective_recover_max( $settings );

        $tasks      = AIAP_Prompts::get_all();
        $today_key  = AIAP_Prompts::get_today_key();
        $now_hi     = current_time( 'H:i' );
        $today_str  = current_time( 'Y-m-d' );
        $last_run   = get_option( self::OPTION_LAST_RUN, array() );
        $now_min    = self::time_to_minutes( $now_hi );
        $window_min = $now_min - self::CHECK_INTERVAL;

        // 当日分: 現在窓 [now-CHECK_INTERVAL, now] の外側のみ。窓内は次回 cron が拾うので除外。
        $today_filter = function ( $time ) use ( $window_min, $now_min ) {
            $sched_min = self::time_to_minutes( $time );
            return ! ( $sched_min >= $window_min && $sched_min <= $now_min );
        };
        $missed = self::collect_runs_for_day(
            $tasks, $today_str, $today_key, $last_run, $now_hi, $today_filter, array()
        );

        // 過去 RECOVER_DAYS 日分は recover_enabled のときだけ追加
        if ( $recover_enabled ) {
            $missed = array_merge(
                $missed,
                self::collect_runs_for_past_days( $tasks, $last_run, array() )
            );
        }

        if ( self::should_skip_initial_missed_scan( $tasks ) ) {
            self::mark_initial_missed_as_completed( $missed, $last_run );
            return 0;
        }

        // recover OFF + missed あり: 全部捨てる (last_run マーク + ログ)。
        // 「OFF = 取りこぼしを無視する」を明示的に表現 (放置すると次回 ON にしたとき
        // 過去のものを拾ってしまう)。
        if ( ! $recover_enabled && ! empty( $missed ) ) {
            $dropped_times = array();
            foreach ( $missed as $d ) {
                $last_run[ $d['run_key'] ] = current_time( 'mysql' );
                $dropped_times[] = self::format_schedule_slot( $d['date_str'] ?? '', $d['time'] ?? '' );
            }
            update_option( self::OPTION_LAST_RUN, $last_run, false );
            AIAP_Logger::add( 'info', 'log_' . $source . '_recover_off', array(
                count( $dropped_times ), implode( ', ', $dropped_times ),
            ));
            return 0;
        }

        if ( empty( $missed ) ) { return 0; }

        // 新しい順から recover_max 件採用、超過分は drop ログ。kept は古い順。
        $kept = self::trim_and_log_missed( $missed, $recover_max, 'log_' . $source . '_dropped_old' );

        $queue_result = self::purge_and_add_to_queue( $kept, $today_str, $last_run );
        $added        = $queue_result['added'];

        if ( $added > 0 ) {
            self::set_queue( $queue_result['queue'] );
            $times = array_map( function ( $c ) {
                return self::format_schedule_slot( $c['date_str'] ?? '', $c['time'] ?? '' );
            }, $kept );
            AIAP_Logger::add( 'info', 'log_' . $source . '_enqueued', array(
                $added, implode( ', ', $times ),
            ));
        }

        return $added;
    }

    /**
     * 旧 API 互換: 手動経路向けのラッパー。
     * 既存の呼び出し元を一気に置き換える破壊変更を避けるため残す。
     * 新規コードは enqueue_missed_after_post( 'manual' ) を直接呼ぶこと。
     *
     * @return int
     */
    public static function enqueue_missed_for_manual() {
        return self::enqueue_missed_after_post( 'manual' );
    }

    /**
     * 初回インストール直後は、登録済みタスクの過去時刻を「取りこぼし」として復元しない。
     *
     * 初回の自動実行・手動実行で過去分を実行済み扱いにして、以降の通常運用では
     * 取りこぼし復元を有効にする。
     */
    private static function should_skip_initial_missed_scan( $tasks ) {
        if ( get_option( self::OPTION_INITIAL_MISSED_SCAN_DONE, false ) ) {
            return false;
        }

        if ( empty( $tasks ) || ! is_array( $tasks ) ) {
            return false;
        }

        foreach ( $tasks as $task ) {
            if ( ! empty( $task['enabled'] ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * 初回の過去未投稿確認スキップを完了扱いにする。
     */
    private static function mark_initial_missed_scan_done() {
        update_option( self::OPTION_INITIAL_MISSED_SCAN_DONE, 1, false );
    }

    /**
     * ログ表示用に予定日時を1つの表記へそろえる。
     * ログ時刻は実行時刻、ここに出す日時は「対象スケジュール」。
     */
    private static function format_schedule_slot( $date_str, $time ) {
        $date_str = is_string( $date_str ) ? trim( $date_str ) : '';
        $time     = is_string( $time ) ? trim( $time ) : '';
        if ( $date_str !== '' && $time !== '' ) {
            return $date_str . ' ' . $time;
        }
        if ( $time !== '' ) {
            return $time;
        }
        return $date_str;
    }

    /**
     * Bug fix (audit r9 self-audit, #37): スケジュール走査の重複解消。
     *
     * 旧実装は run_locked と enqueue_missed_for_manual の双方で、ほぼ同一の
     * タスク schedule 走査ループ (合計 79 行) を別々に書いていた。jscpd 検出値で
     * #2/#3/#4/#5/#6/#7 が該当。片方を直してもう片方を直し忘れる事故が起きやすい
     * 構造で、実際これまでの監査でも片側だけ確認している箇所があった。
     * このヘルパー一式に集約することで、走査ロジックは 1 箇所に統一される。
     */

    /**
     * 指定の date_str + day_key について、該当する全 task の schedule を走査し
     * 「未実行のスケジュール」候補配列を返す。
     *
     * @param array         $tasks         AIAP_Prompts::get_all() の戻り値
     * @param string        $date_str      e.g. "2026-05-08"
     * @param string        $day_key       e.g. "thu" (AIAP_Prompts::day_number_to_key()) の値
     * @param array         $last_run      OPTION_LAST_RUN
     * @param string        $now_hi_filter 当日扱いで未来時刻をスキップしたい場合の "H:i"。"" なら無効
     * @param callable|null $time_filter   ($time_hhmm) => bool / 採用するなら true / 追加条件用
     * @param array         $extra_fields  candidate に埋め込む追加キー (e.g. ['is_missed' => false])
     * @return array
     */
    private static function collect_runs_for_day( $tasks, $date_str, $day_key, $last_run, $now_hi_filter, $time_filter, array $extra_fields ) {
        $today_str  = current_time( 'Y-m-d' );
        $candidates = array();
        foreach ( $tasks as $task ) {
            if ( empty( $task['enabled'] ) ) { continue; }

            // ランダムモード (#47): schedule[$day_key] ではなく random_today キャッシュを使う。
            // 過去日の場合は対応しない (ランダム時刻は今日分のみ生成・保存)。
            if ( AIAP_Prompts::is_random_mode_active( $task ) ) {
                if ( $date_str !== $today_str ) {
                    // 過去日 (取りこぼし救済対象) はランダムタスクには適用しない
                    continue;
                }
                $times = self::get_random_times_for_today( $task );
            } else {
                $sched = isset( $task['schedule'] ) ? $task['schedule'] : array();
                if ( empty( $sched[ $day_key ] ) || ! is_array( $sched[ $day_key ] ) ) { continue; }
                $times = $sched[ $day_key ];
            }

            foreach ( $times as $scheduled_time ) {
                $time = trim( (string) $scheduled_time );
                if ( $time === '' ) { continue; }
                if ( $now_hi_filter !== '' && $time > $now_hi_filter ) { continue; }
                if ( is_callable( $time_filter ) && ! call_user_func( $time_filter, $time ) ) {
                    continue;
                }
                $run_key = $date_str . '_' . $task['id'] . '_' . $time;
                if ( self::is_run_completed( $run_key, $last_run, $task['id'], $date_str, $time ) ) {
                    continue;
                }
                $candidates[] = array_merge( array(
                    'time'     => $time,
                    'task_id'  => $task['id'],
                    'run_key'  => $run_key,
                    'date_str' => $date_str,
                ), $extra_fields );
            }
        }
        return $candidates;
    }

    /**
     * 過去 RECOVER_DAYS 日分の schedule を走査し、未実行候補を返す。
     * 各日について collect_runs_for_day を曜日ベースで呼び出す。
     *
     * @param array $tasks
     * @param array $last_run
     * @param array $extra_fields candidate に埋め込む追加キー
     * @return array
     */
    private static function collect_runs_for_past_days( $tasks, $last_run, array $extra_fields ) {
        $candidates = array();
        $today_num  = (int) current_time( 'w' );
        for ( $i = 1; $i <= self::RECOVER_DAYS; $i++ ) {
            $target_str = self::date_str_days_ago( $i );
            $target_num = ( $today_num - $i + 7 ) % 7;
            $target_key = AIAP_Prompts::day_number_to_key( $target_num );
            $candidates = array_merge(
                $candidates,
                self::collect_runs_for_day( $tasks, $target_str, $target_key, $last_run, '', null, $extra_fields )
            );
        }
        return $candidates;
    }

    /**
     * initial missed scan のスキップ判定が真のとき、現在抽出済みの missed を
     * 全部「実行済み」として last_run に書き込み、initial scan 完了マークを立てる。
     *
     * @param array $missed
     * @param array $last_run (by ref) 書き換え後にも呼び出し側が参照する
     * @return void
     */
    private static function mark_initial_missed_as_completed( array $missed, array &$last_run ) {
        if ( ! empty( $missed ) ) {
            foreach ( $missed as $d ) {
                if ( ! empty( $d['run_key'] ) ) {
                    $last_run[ $d['run_key'] ] = current_time( 'mysql' );
                }
            }
            update_option( self::OPTION_LAST_RUN, $last_run, false );
        }
        self::mark_initial_missed_scan_done();
    }

    /**
     * 取りこぼしを (date_str + time) 降順 → recover_max 件まで採用 →
     * 古い分は dropped へ。 dropped 件数があれば $log_key でログ記録。
     * kept は古い順に並べ替えて返す (post_date 整合のため)。
     *
     * @param array  $missed
     * @param int    $recover_max
     * @param string $log_key  log_auto_dropped_old / log_manual_dropped_old
     * @return array kept (古い順)
     */
    private static function trim_and_log_missed( array $missed, $recover_max, $log_key ) {
        usort( $missed, function ( $a, $b ) {
            return strcmp(
                $b['date_str'] . ' ' . $b['time'],
                $a['date_str'] . ' ' . $a['time']
            );
        } );
        $kept    = array_slice( $missed, 0, (int) $recover_max );
        $dropped = array_slice( $missed, (int) $recover_max );

        if ( ! empty( $dropped ) ) {
            $dropped_times = array();
            foreach ( $dropped as $d ) {
                $dropped_times[] = self::format_schedule_slot( $d['date_str'] ?? '', $d['time'] ?? '' );
            }
            AIAP_Logger::add( 'info', $log_key, array(
                $recover_max, count( $dropped ), implode( ', ', $dropped_times ),
            ) );
        }

        usort( $kept, function ( $a, $b ) {
            return strcmp(
                $a['date_str'] . ' ' . $a['time'],
                $b['date_str'] . ' ' . $b['time']
            );
        } );
        return $kept;
    }

    /**
     * 既存キューを purge して候補を追加する。
     * Bug fix (audit r9 self-audit, #37): run_locked と enqueue_missed_for_manual の
     * キュー操作部分が完全にコピペで重複していたためヘルパー化。
     *
     * purge ルール:
     *   - date_str が空 / 未来日 / RECOVER_DAYS より古い → 削除
     *   - run_key/task_id/time が欠けている → 削除
     *   - is_future_schedule で未来時刻 → 削除
     *   - last_run に既に記録あり → 削除 (DB クエリ無しの軽量チェック)
     *
     * @param array  $candidates 追加候補 (呼び出し側で順序済み)
     * @param string $today_str  Y-m-d
     * @param array  $last_run
     * @return array ['queue' => 新しいキュー, 'added' => 追加件数]
     */
    private static function purge_and_add_to_queue( array $candidates, $today_str, array $last_run ) {
        $queue     = self::get_queue();
        $keep_from = self::date_str_days_ago( self::RECOVER_DAYS );
        $queue = array_values( array_filter( $queue, function ( $q ) use ( $today_str, $keep_from, $last_run ) {
            if ( empty( $q['date_str'] ) ) { return false; }
            if ( $q['date_str'] > $today_str ) { return false; }
            if ( $q['date_str'] < $keep_from ) { return false; }
            if ( empty( $q['run_key'] ) || empty( $q['task_id'] ) || empty( $q['time'] ) ) { return false; }
            if ( self::is_future_schedule( $q['date_str'], $q['time'] ) ) { return false; }
            if ( is_array( $last_run ) && isset( $last_run[ $q['run_key'] ] ) ) { return false; }
            return true;
        } ) );

        $existing = array();
        foreach ( $queue as $q ) { $existing[ $q['run_key'] ] = true; }

        $added = 0;
        foreach ( $candidates as $c ) {
            if ( isset( $existing[ $c['run_key'] ] ) ) { continue; }
            if ( empty( $c['date_str'] ) ) { continue; }
            $queue[] = array(
                'time'     => $c['time'],
                'task_id'  => $c['task_id'],
                'date_str' => $c['date_str'],
                'run_key'  => $c['run_key'],
                'queued_at'=> time(),
            );
            $existing[ $c['run_key'] ] = true;
            $added++;
        }

        return array( 'queue' => $queue, 'added' => $added );
    }

    /**
     * 指定スケジュールが処理済みか判定する。
     *
     * last_run が消えた場合でも、投稿メタと予定日時から既存投稿を確認し、
     * 過去分を全て取りこぼし扱いにする事故を防止する。
     */
    /**
     * Free / Pro プランに応じた cron_recover_max の実効値を返す。
     *
     * - Pro: 設定値 (1〜5 にクランプ)
     * - Free: 強制 1 (UI/フォームの disabled が突破されても、cron 実行時にここで強制)
     *
     * 新規インストールのデフォルトは 3 で、handle_save_settings は保存時のみ
     * Free=1 に書き換える。設定未保存の Free プランや Pro→Free に戻ったケースで
     * 旧値が使われると Free プランの制限が破られるため、ここで実効値を統一する。
     */
    private static function effective_recover_max( $settings ) {
        $value = max( 1, (int) ( $settings['cron_recover_max'] ?? 3 ) );
        if ( class_exists( 'AIAP_License' ) && ! AIAP_License::is_pro() ) {
            return 1; // Free プランは常に 1 件固定
        }
        return min( 5, $value );
    }

    private static function is_run_completed( $run_key, $last_run, $task_id, $date_str, $time ) {
        if ( is_array( $last_run ) && isset( $last_run[ $run_key ] ) ) {
            return true;
        }

        if ( self::post_exists_for_run_key( $run_key ) ) {
            return true;
        }

        return self::post_exists_for_schedule( $task_id, $date_str, $time );
    }

    /**
     * 新方式: 投稿に保存した run_key で重複を判定する。
     */
    /**
     * Bug fix (audit r9 self-audit, #39): post_exists_for_* で 3 回反復していた
     * WP_Query 引数の定型を 1 箇所に集約する。
     *
     * post_exists_for_run_key と post_exists_for_schedule の双方が、生成された投稿を
     * 軽量に検索するための同じ引数セット (post_type, post_status, fields, no_found_rows
     * 等) を使う。jscpd #8/#9 の検出箇所。引数を変えるべき場面が出たら 1 箇所だけ直せば
     * 全体が追従するようにする。
     *
     * @return array get_posts() の defaults
     */
    private static function aiap_post_query_defaults() {
        return array(
            'post_type'              => 'post',
            'post_status'            => array( 'publish', 'draft', 'pending', 'future', 'private' ),
            'fields'                 => 'ids',
            'posts_per_page'         => 1,
            'no_found_rows'          => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        );
    }

    private static function post_exists_for_run_key( $run_key ) {
        if ( empty( $run_key ) ) {
            return false;
        }

        $ids = get_posts( array_merge( self::aiap_post_query_defaults(), array(
            'meta_query' => array(
                array(
                    'key'   => '_aiap_schedule_run_key',
                    'value' => $run_key,
                ),
            ),
        ) ) );

        return ! empty( $ids );
    }

    /**
     * 旧方式互換: run_key 保存前の投稿でも、タスクID + 予定投稿日時で重複を判定する。
     */
    private static function post_exists_for_schedule( $task_id, $date_str, $time ) {
        if ( empty( $task_id ) || empty( $date_str ) || empty( $time ) ) {
            return false;
        }

        // fixed23以降の投稿は、投稿日時ではなく予定メタで判定する。
        // post_date は秒/GMT/DB形式差で一致しないことがあるため、メタを優先する。
        $ids = get_posts( array_merge( self::aiap_post_query_defaults(), array(
            'meta_query' => array(
                'relation' => 'AND',
                array(
                    'key'   => '_aiap_generated',
                    'value' => 1,
                ),
                array(
                    'key'   => '_aiap_task_id',
                    'value' => $task_id,
                ),
                array(
                    'key'   => '_aiap_scheduled_date',
                    'value' => $date_str,
                ),
                array(
                    'key'   => '_aiap_scheduled_time',
                    'value' => $time,
                ),
            ),
        ) ) );

        if ( ! empty( $ids ) ) {
            return true;
        }

        // 旧方式互換: メタ保存前の投稿は、予定時刻の1分範囲でpost_dateを確認する。
        $scheduled_start = $date_str . ' ' . $time . ':00';
        $scheduled_end   = $date_str . ' ' . $time . ':59';
        $ids = get_posts( array_merge( self::aiap_post_query_defaults(), array(
            'meta_query' => array(
                'relation' => 'AND',
                array(
                    'key'   => '_aiap_generated',
                    'value' => 1,
                ),
                array(
                    'key'   => '_aiap_task_id',
                    'value' => $task_id,
                ),
            ),
            'date_query' => array(
                array(
                    'column'    => 'post_date',
                    'after'     => $scheduled_start,
                    'before'    => $scheduled_end,
                    'inclusive' => true,
                ),
            ),
        ) ) );

        return ! empty( $ids );
    }

    /**
     * 指定スケジュールが現在より未来かを判定する。
     *
     * 未来の予定を取りこぼしキューとして即時実行しないための安全弁。
     */
    private static function is_future_schedule( $date_str, $time ) {
        if ( empty( $date_str ) || empty( $time ) ) {
            return false;
        }

        // strtotime() は PHP デフォルト TZ を使うため、wp_timezone() で揃える
        try {
            $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( date_default_timezone_get() );
            $dt = new DateTimeImmutable( $date_str . ' ' . $time . ':00', $tz );
            $scheduled_ts = $dt->getTimestamp();
        } catch ( Exception $e ) {
            return false;
        }
        if ( ! $scheduled_ts ) {
            return false;
        }

        return $scheduled_ts > time();
    }

    private static function time_to_minutes( $hhmm ) {
        $parts = explode( ':', $hhmm );
        return (int) $parts[0] * 60 + (int) ( $parts[1] ?? 0 );
    }

    /**
     * サイト TZ で「N 日前の同じ暦日」の Y-m-d 文字列を返す。
     *
     * strtotime('-N days') は固定の 86400×N 秒減算なので DST 遷移を含む期間で
     * 暦日が 1 日ずれる可能性がある。DateTimeImmutable + wp_timezone() で TZ-aware
     * に減算することで、サマータイムのある TZ でも安全に「N 日前」が取れる。
     *
     * @param int $days
     * @return string Y-m-d
     */
    private static function date_str_days_ago( $days ) {
        try {
            $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( date_default_timezone_get() );
            $dt = new DateTimeImmutable( 'now', $tz );
            return $dt->modify( '-' . (int) $days . ' days' )->format( 'Y-m-d' );
        } catch ( Exception $e ) {
            // フォールバック: 旧実装互換
            return wp_date( 'Y-m-d', strtotime( '-' . (int) $days . ' days', time() ) );
        }
    }

    // ============================================================
    // ランダム投稿モード関連 (#47)
    // ============================================================

    /**
     * 指定タスクの今日のランダム投稿時刻を取得 (キャッシュ済みなら返す、なければ生成)。
     *
     * @param array $task
     * @return array HH:MM の配列 (古い順ソート済み)。0 件 (= 今日は投稿しない) もあり。
     */
    public static function get_random_times_for_today( array $task ) {
        if ( ! AIAP_Prompts::is_random_mode_active( $task ) ) {
            return array();
        }
        $task_id   = isset( $task['id'] ) ? (string) $task['id'] : '';
        if ( $task_id === '' ) { return array(); }

        $today_str = current_time( 'Y-m-d' );
        $cache     = get_option( self::OPTION_RANDOM_TIMES, array() );
        if ( ! is_array( $cache ) ) { $cache = array(); }

        // キャッシュが今日分ならそれを返す
        if ( isset( $cache[ $task_id ]['date_str'] )
             && $cache[ $task_id ]['date_str'] === $today_str
             && isset( $cache[ $task_id ]['times'] )
             && is_array( $cache[ $task_id ]['times'] ) ) {
            return $cache[ $task_id ]['times'];
        }

        // 今日分を生成して保存
        $times = self::generate_random_times( $task );
        $cache[ $task_id ] = array(
            'date_str' => $today_str,
            'times'    => $times,
        );

        // 古いエントリ (date_str が今日でないもの) を掃除
        foreach ( $cache as $k => $v ) {
            if ( ! isset( $v['date_str'] ) || $v['date_str'] !== $today_str ) {
                unset( $cache[ $k ] );
            }
        }
        $cache[ $task_id ] = array(
            'date_str' => $today_str,
            'times'    => $times,
        );
        update_option( self::OPTION_RANDOM_TIMES, $cache, false );

        return $times;
    }

    /**
     * タスクのランダム時刻を生成 (5 分刻み、allowed_periods 内から min_interval を尊重して抽出)。
     * 回数は random_count_min〜random_count_max からランダム決定。
     *
     * 新仕様 (Phase 13):
     * - allowed_periods が空配列のとき: 24 時間すべて選択候補
     * - allowed_periods が非空のとき: いずれかの period に含まれる時刻のみが選択候補
     * - min_interval_minutes > 0 のとき: 選ばれた時刻同士の最小間隔として強制
     * - 候補不足等で count まで埋められない場合は埋まった分だけ返す
     *
     * @param array $task
     * @return array HH:MM の配列 (古い順ソート済み)
     */
    private static function generate_random_times( array $task ) {
        $min = isset( $task['random_count_min'] ) ? (int) $task['random_count_min'] : 0;
        $max = isset( $task['random_count_max'] ) ? (int) $task['random_count_max'] : 0;
        $min = max( 0, min( AIAP_Prompts::RANDOM_COUNT_MAX, $min ) );
        $max = max( $min, min( AIAP_Prompts::RANDOM_COUNT_MAX, $max ) );
        if ( $max === 0 ) { return array(); }

        $count = mt_rand( $min, $max );
        if ( $count === 0 ) { return array(); }

        $allowed = isset( $task['allowed_periods'] ) && is_array( $task['allowed_periods'] )
            ? $task['allowed_periods'] : array();
        $min_gap = isset( $task['min_interval_minutes'] ) ? (int) $task['min_interval_minutes'] : 0;
        if ( $min_gap < 0 ) { $min_gap = 0; }

        // 全 5 分刻みスロット (00:00, 00:05, ..., 23:55)
        // allowed が空なら全 288 スロット、非空なら is_time_in_allowed_periods でフィルタ
        $available = array();
        for ( $h = 0; $h < 24; $h++ ) {
            for ( $m = 0; $m < 60; $m += AIAP_Prompts::RANDOM_TIME_STEP ) {
                $t = sprintf( '%02d:%02d', $h, $m );
                if ( self::is_time_in_allowed_periods( $t, $allowed ) ) {
                    $available[] = $t;
                }
            }
        }

        if ( empty( $available ) ) { return array(); }
        $count = min( $count, count( $available ) );

        // min_gap = 0 のときは shuffle + slice の高速パス
        if ( $min_gap === 0 ) {
            shuffle( $available );
            $picked = array_slice( $available, 0, $count );
            sort( $picked );
            return $picked;
        }

        // min_gap > 0: 候補をシャッフルし、既選択との距離が min_gap 分未満なら不採用。
        // 距離は HH:MM の絶対分差で計算 (同一日内の選択なので日跨ぎは考慮しない)。
        shuffle( $available );
        $picked_minutes = array();
        $picked         = array();
        $time_to_min    = function ( $hhmm ) {
            $parts = explode( ':', $hhmm );
            return ( (int) $parts[0] ) * 60 + (int) $parts[1];
        };
        foreach ( $available as $cand ) {
            $cand_min = $time_to_min( $cand );
            $ok       = true;
            foreach ( $picked_minutes as $pm ) {
                if ( abs( $cand_min - $pm ) < $min_gap ) {
                    $ok = false;
                    break;
                }
            }
            if ( $ok ) {
                $picked[]         = $cand;
                $picked_minutes[] = $cand_min;
                if ( count( $picked ) >= $count ) { break; }
            }
        }
        sort( $picked );
        return $picked;
    }

    /**
     * 仕様変更時の組み換え (Phase 13 案 A): 既に過ぎた時刻 (now 以前) は変更せず、
     * now 以降の時刻を新しい allowed_periods / min_interval / count_min..max で再抽選する。
     *
     * 呼び出し側 (AIAP_Prompts::update_random_cache_on_save) が今日のキャッシュ存在を
     * 確認してから呼ぶこと。本関数は引数の current_times をそのまま使う。
     *
     * 動作:
     * 1. now 以前の時刻 = "past_times" として確定保持 (last_run と整合)
     * 2. 新 count = mt_rand(new_min, new_max)。past 件数より少なければ 0 件 future 追加
     * 3. 未来候補 = 5 分刻み、now より後、新 allowed_periods 内
     * 4. 選択時に min_interval は past + 既選択 future の両方に対して守る
     *
     * @param array $task          新しい設定の task
     * @param array $current_times 旧キャッシュの時刻配列 (HH:MM)
     * @return array 組み換え後の時刻配列 (HH:MM、ソート済み)
     */
    public static function regenerate_future_random_times( array $task, array $current_times ) {
        $now_hi = current_time( 'H:i' );

        // past_times: now 以前 (= 文字列比較で <=)
        $past_times = array();
        foreach ( $current_times as $t ) {
            if ( is_string( $t ) && preg_match( '/^\d{2}:\d{2}$/', $t ) && $t <= $now_hi ) {
                $past_times[] = $t;
            }
        }
        sort( $past_times );

        $time_to_min = function ( $hhmm ) {
            $parts = explode( ':', $hhmm );
            return ( (int) $parts[0] ) * 60 + (int) $parts[1];
        };
        $past_minutes = array_map( $time_to_min, $past_times );

        // 新設定の count レンジ
        $min = isset( $task['random_count_min'] ) ? (int) $task['random_count_min'] : 0;
        $max = isset( $task['random_count_max'] ) ? (int) $task['random_count_max'] : 0;
        $min = max( 0, min( AIAP_Prompts::RANDOM_COUNT_MAX, $min ) );
        $max = max( $min, min( AIAP_Prompts::RANDOM_COUNT_MAX, $max ) );

        if ( $max === 0 ) {
            // 新設定: 投稿しない。past だけ保持
            return $past_times;
        }

        $target_count  = mt_rand( $min, $max );
        $needed_future = max( 0, $target_count - count( $past_times ) );

        if ( $needed_future === 0 ) {
            // past がすでに新 target 以上ある → 未来追加なし
            return $past_times;
        }

        // 未来候補を構築
        $allowed = isset( $task['allowed_periods'] ) && is_array( $task['allowed_periods'] )
            ? $task['allowed_periods'] : array();
        $min_gap = isset( $task['min_interval_minutes'] ) ? (int) $task['min_interval_minutes'] : 0;
        if ( $min_gap < 0 ) { $min_gap = 0; }

        $now_min    = $time_to_min( $now_hi );
        $candidates = array();
        for ( $h = 0; $h < 24; $h++ ) {
            for ( $m = 0; $m < 60; $m += AIAP_Prompts::RANDOM_TIME_STEP ) {
                $t_min = $h * 60 + $m;
                if ( $t_min <= $now_min ) { continue; } // 未来のみ
                $t = sprintf( '%02d:%02d', $h, $m );
                if ( ! self::is_time_in_allowed_periods( $t, $allowed ) ) { continue; }
                $candidates[] = $t;
            }
        }

        if ( empty( $candidates ) ) {
            // 候補がない (allowed が制限的すぎ等) → past のみ保持
            return $past_times;
        }

        shuffle( $candidates );
        $picked_future  = array();
        $picked_minutes = $past_minutes; // past との間隔も守る

        if ( $min_gap === 0 ) {
            $picked_future = array_slice( $candidates, 0, min( $needed_future, count( $candidates ) ) );
        } else {
            foreach ( $candidates as $cand ) {
                $cm = $time_to_min( $cand );
                $ok = true;
                foreach ( $picked_minutes as $pm ) {
                    if ( abs( $cm - $pm ) < $min_gap ) {
                        $ok = false;
                        break;
                    }
                }
                if ( $ok ) {
                    $picked_future[]  = $cand;
                    $picked_minutes[] = $cm;
                    if ( count( $picked_future ) >= $needed_future ) { break; }
                }
            }
        }

        $final = array_merge( $past_times, $picked_future );
        sort( $final );
        return array_values( $final );
    }

    /**
     * 時刻 HH:MM が allowed_periods のいずれかに含まれるか判定
     * - allowed が空配列: 常に true (= 24h 全許可、新仕様の default 動作)
     * - start <= end: 通常範囲。start <= time < end なら含まれる
     * - start > end:  日跨ぎ。time >= start || time < end なら含まれる (例 22:00–07:00)
     *
     * @param string $time     'HH:MM'
     * @param array  $allowed  [{start, end}, ...]
     * @return bool
     */
    private static function is_time_in_allowed_periods( $time, $allowed ) {
        if ( empty( $allowed ) || ! is_array( $allowed ) ) { return true; } // 空 = 全許可
        foreach ( $allowed as $period ) {
            $start = isset( $period['start'] ) ? (string) $period['start'] : '';
            $end   = isset( $period['end'] ) ? (string) $period['end'] : '';
            if ( $start === '' || $end === '' ) { continue; }
            if ( $start <= $end ) {
                if ( $time >= $start && $time < $end ) { return true; }
            } else {
                // 日跨ぎ
                if ( $time >= $start || $time < $end ) { return true; }
            }
        }
        return false;
    }

    /**
     * 次回実行予定を全タスクのスケジュールから計算
     */
    public static function get_next_run() {
        $tasks     = AIAP_Prompts::get_all();
        // TZ混在を避けるため、全てWP設定TZの文字列ベースで処理
        $today_num = (int) current_time( 'w' );
        $now_hi    = current_time( 'H:i' );
        $today_str = current_time( 'Y-m-d' );
        $best      = null;

        // offset 0..7 で 1週間+1日先まで走査 (来週同曜日 offset=7 を含めるため <= 7)
        // これにより「今日の過ぎた時刻のタスクが今週は実行されない」場合に
        // 来週同曜日のタスクを次回実行予定として正しく返せる。
        for ( $offset = 0; $offset <= 7; $offset++ ) {
            $check_num = ( $today_num + $offset ) % 7;
            $check_key = AIAP_Prompts::day_number_to_key( $check_num );

            foreach ( $tasks as $task ) {
                if ( empty( $task['enabled'] ) ) {
                    continue;
                }
                $sched = isset( $task['schedule'] ) ? $task['schedule'] : array();
                if ( empty( $sched[ $check_key ] ) ) {
                    continue;
                }

                foreach ( $sched[ $check_key ] as $time ) {
                    // 当日(offset=0)は現在時刻以降のみ。offset=7 は来週同曜日扱いなので時刻フィルタ不要。
                    if ( $offset === 0 && $time <= $now_hi ) {
                        continue;
                    }
                    // WP設定TZの今日から N日後の日付を計算
                    $base = date_create( $today_str );
                    if ( ! $base ) {
                        continue;
                    }
                    $next_date = $base->modify( "+{$offset} days" )->format( 'Y-m-d' );
                    $candidate = $next_date . ' ' . $time . ':00';

                    if ( $best === null || $candidate < $best ) {
                        $best = $candidate;
                    }
                }
            }
        }

        return $best;
    }

    public static function reschedule() {
        self::unschedule();

        $settings = AIAP_AI_Client::get_settings();
        if ( empty( $settings['enabled'] ) ) {
            return;
        }

        if ( ! wp_next_scheduled( AIAP_CRON_HOOK ) ) {
            wp_schedule_event( time() + 30, 'aiap_five_minutes', AIAP_CRON_HOOK );
        }
    }

    public static function unschedule() {
        // WP 5.1+ の wp_clear_scheduled_hook は同一フック名の全 cron 登録を1回で解除する
        wp_clear_scheduled_hook( AIAP_CRON_HOOK );
    }

    public static function activate() {
        self::reschedule();
    }

    public static function deactivate() {
        // cron だけ停止する。各 option はユーザが再有効化した時に使うので残す。
        // option の完全クリーンアップは uninstall.php で行う(プラグイン削除時のみ)。
        self::unschedule();
    }
}
