<?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';         // 実行待ちキュー(取りこぼし含む)

    // ロックキーは AIAP_Lock_Keys に集約。本定数は後方互換のためのエイリアス。

    const LOCK_KEY_RUN_QUEUE       = AIAP_Lock_Keys::RUN_QUEUE; // queue R/M/W 用ロックキー

    const OPTION_INITIAL_MISSED_SCAN_DONE = 'aiap_initial_missed_scan_done'; // 初回インストール後の過去未投稿確認スキップ済みフラグ



    /**

     * Bug fix: cron_recover_missed が OFF→ON に変わったタイムスタンプ。

     *

     * 旧実装は initial_missed_scan が一度完了するとフラグが永続化され、後で recover_missed

     * を ON に戻したとき、過去 RECOVER_DAYS 日分の幽霊スロットがすべて missed としてキュー

     * に積まれていた。「ON に切り替えた時点より前」のスロットは silent bury 扱いとする。

     *

     * 値: int (UNIX 秒)。0 または未設定 = OFF または未遷移。

     */

    const OPTION_RECOVER_ENABLED_FROM = 'aiap_recover_enabled_from';

    // #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( AIAP_Lock_Keys::SCHEDULER_RUN, 300 );

        if ( $lock_token === false ) {

            return;

        }



        // try/finally で必ずロックを解放する(致命的エラーが起きても解放)

        try {

            self::run_locked( $settings );

        } finally {

            AIAP_Lock::release( AIAP_Lock_Keys::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() );



        // run_locked 内では recover_enabled / recover_max は使わない

        // (Phase 1(b)/(c) の取りこぼし収集は enqueue_missed_after_post に委譲して値を取得する)。

        // 旧実装はここで計算したまま参照されないデッドコードを残していた。



        $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() で

        //    「掃除のみ」を実行できる。期限切れ・未来日・実行済みを除外する)

        //

        // 並行性対策 (spec: 2026-05-15-concurrency-fixes): get_queue → 加工 → set_queue を

        // run_queue ロックで atomic 化する。cron 同士は scheduler_run で既に直列化されている

        // ため通常は競合しないが、Quick Post が同タイミングで queue を更新するケースを保護。

        // run_queue ロック保護下でキューを掃除し、先頭を取り出す。

        // ロック取得失敗時は今回の cron をスキップ (return false 経路) し、次回に委ねる。

        // ヘルパ from D3 リファクタ (CLAUDE.md 重複排除)。

        $queue_op = self::with_queue_lock( function () use ( $today_str, $last_run ) {

            $queue_result = self::purge_and_add_to_queue( array(), $today_str, $last_run );

            $queue        = $queue_result['queue'];

            $head_local   = null;

            if ( ! empty( $queue ) ) {

                $head_local = array_shift( $queue );

            }

            // Bug fix (audit r9 self-audit, #43): キューが空でも掃除済みの状態を必ず保存する。

            self::set_queue( $queue );

            return $head_local;

        }, false );

        if ( $queue_op === false ) {

            // ロック取得失敗で今回をスキップ

            return;

        }

        $head = $queue_op;



        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() );



        // 実行開始ログは Poster::log_run_started に集約 (CLAUDE.md「同じ処理は共通化」)。

        // 手動経路 (admin-runner.php) も同ヘルパを source='src_manual' で呼ぶ。

        AIAP_Poster::log_run_started( 'src_auto', array(

            'is_late'         => (bool) $is_late,

            'minutes_late'    => (int) $diff_min,

            'date_str'        => $head['date_str'],

            'time'            => $head['time'],

            'task_label'      => $task_label,

            'queue_remaining' => $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 );



        // try/finally で OVERRIDE_POSTDATE の削除を必ず保証する。

        // 旧実装は run_single 内で Throwable が発生した場合に delete_option が呼ばれず、

        // 次回 cron で別タスクへ誤適用される可能性があった (300 秒以内の場合)。

        try {

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

        } finally {

            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' );



        // 並行性対策: get_queue → 加工 → set_queue を run_queue ロックで atomic 化。

        // 共通テンプレートは with_queue_lock に集約 (CLAUDE.md 重複排除)。

        // ロック取得失敗時は 0 を返して今回の取りこぼし追加をスキップ (次回 cron で再収集)。

        $added = self::with_queue_lock( function () use ( $kept, $today_str, $last_run, $source ) {

            $queue_result = self::purge_and_add_to_queue( $kept, $today_str, $last_run );

            $added_local  = $queue_result['added'];

            if ( $added_local > 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_local, implode( ', ', $times ),

                ));

            }

            return $added_local;

        }, 0 );



        return (int) $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' );

        // Bug fix: cron_recover_missed が OFF→ON に切り替わった瞬間より前の日付は対象外。

        // 切替前は「取りこぼし扱いされていなかった」ので、後から ON にしたからといって

        // 過去の予定を遡及投稿しないのが直感に近い。OPTION_RECOVER_ENABLED_FROM が

        // 0/未設定なら従来通り過去 RECOVER_DAYS 日すべてを対象にする (旧フラグ互換)。

        $enabled_from = (int) get_option( self::OPTION_RECOVER_ENABLED_FROM, 0 );

        for ( $i = 1; $i <= self::RECOVER_DAYS; $i++ ) {

            $target_str = self::date_str_days_ago( $i );

            if ( $enabled_from > 0 ) {

                try {

                    $tz   = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( date_default_timezone_get() );

                    $dt   = new DateTimeImmutable( $target_str . ' 23:59:59', $tz );

                    if ( $dt->getTimestamp() < $enabled_from ) {

                        continue;

                    }

                } catch ( Exception $e ) {

                    // パース失敗時は安全側 (= 過去日を採用しない) に倒す

                    continue;

                }

            }

            $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;

    }



    /**

     * cron_recover_missed の OFF→ON / ON→OFF 切替を検知して OPTION_RECOVER_ENABLED_FROM

     * を更新する。設定保存ハンドラ (handle_save_recover) から呼ぶ。

     *

     * @param bool $prev_enabled 旧値

     * @param bool $new_enabled  新値

     * @return void

     */

    public static function note_recover_transition( $prev_enabled, $new_enabled ) {

        if ( ! $prev_enabled && $new_enabled ) {

            update_option( self::OPTION_RECOVER_ENABLED_FROM, time(), false );

        } elseif ( $prev_enabled && ! $new_enabled ) {

            delete_option( self::OPTION_RECOVER_ENABLED_FROM );

        }

    }



    /**

     * 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();

    }



    /**

     * run_queue ロックで保護された操作を実行する共通テンプレート

     *

     * 旧実装は run() / enqueue_missed_after_post() で同じ「acquire_with_retry →

     * 失敗時 log + return → try/finally release」を独立に書いていた

     * (CLAUDE.md「同一処理を複数箇所に書かない」)。

     *

     * 動作:

     *   - AIAP_Lock 利用可能 + ロック取得成功 → $fn を呼んで戻り値を返す

     *   - AIAP_Lock 利用可能 + ロック取得失敗 → 警告ログを残して $fail_return を返す (= 今回の処理をスキップ)

     *   - AIAP_Lock 未ロード (uninstall 等の異常時) → ロック無しで $fn を呼ぶ

     *

     * @param callable $fn          ロック保護下で実行する関数 (引数なし、任意の値を返す)

     * @param mixed    $fail_return ロック取得失敗時に返す値 (例: null / 0 / void なら null)

     * @return mixed $fn の戻り値、または $fail_return

     */

    private static function with_queue_lock( callable $fn, $fail_return = null ) {

        if ( ! class_exists( 'AIAP_Lock' ) ) {

            return $fn();

        }

        // TTL=30s, 10 回 × 200ms = 最大 2 秒待機（queue 操作は短時間）

        $token = AIAP_Lock::acquire_with_retry( self::LOCK_KEY_RUN_QUEUE, 30, 10, 200000 );

        if ( $token === false ) {

            // ロック取得失敗 → 安全側で今回の処理をスキップ。次回 cron で再評価される。

            AIAP_Logger::add( 'warning', 'log_run_queue_lock_failed', array() );

            return $fail_return;

        }

        try {

            return $fn();

        } finally {

            AIAP_Lock::release( self::LOCK_KEY_RUN_QUEUE, $token );

        }

    }



    /**

     * "HH:MM" 文字列を「その日 0:00 からの分数」に変換するユーティリティ

     *

     * 旧実装はクラス内 private + クロージャ 3 箇所 (generate_random_times /

     * regenerate_future_random_times) + L401 explode が独立に同等処理を持っていた。

     * public 昇格で全箇所から self:: 経由で呼べるようにする。

     *

     * @param string $hhmm "HH:MM" 形式 (秒は無視)

     * @return int 分数 (0〜1439 を想定)

     */

    public static function time_to_minutes( $hhmm ) {

        $parts = explode( ':', (string) $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'];

        }



        // 古いエントリ (date_str が今日でないもの) を先に掃除してから新規エントリを書き込む。

        // 旧実装は cache[$task_id] への代入を foreach の前後で 2 回行っていた (デッドコード) が、

        // 後勝ちの 2 回目で十分なので統一する。

        foreach ( $cache as $k => $v ) {

            if ( ! isset( $v['date_str'] ) || $v['date_str'] !== $today_str ) {

                unset( $cache[ $k ] );

            }

        }

        $times = self::generate_random_times( $task );

        $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)

        $available = self::generate_random_slots( $allowed, -1 );



        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();

        foreach ( $available as $cand ) {

            $cand_min = self::time_to_minutes( $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 );



        $past_minutes = array_map( array( __CLASS__, 'time_to_minutes' ), $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    = self::time_to_minutes( $now_hi );

        $candidates = self::generate_random_slots( $allowed, $now_min );



        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 = self::time_to_minutes( $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 );

    }



    /**

     * 5 分刻みの「ランダム時刻候補スロット」を生成する共通ヘルパ。

     *

     * 旧実装は generate_random_times と regenerate_future_random_times の双方で、

     * `for $h=0..23 / for $m=0..60 step RANDOM_TIME_STEP / is_time_in_allowed_periods`

     * の二重ループをコピペしていた (CLAUDE.md 重複排除)。

     *

     * @param array $allowed_periods [{start, end}, ...]。空なら全 24h 許可

     * @param int   $exclude_at_or_before_min "未来のみ" を求める場合の閾値分 (`now` の分単位)。

     *                                       負値 (< 0) なら閾値なし (全スロットを対象)。

     * @return array<int,string> HH:MM の配列 (時系列順)

     */

    private static function generate_random_slots( array $allowed_periods, $exclude_at_or_before_min ) {

        $exclude_at_or_before_min = (int) $exclude_at_or_before_min;

        $slots = array();

        for ( $h = 0; $h < 24; $h++ ) {

            for ( $m = 0; $m < 60; $m += AIAP_Prompts::RANDOM_TIME_STEP ) {

                if ( $exclude_at_or_before_min >= 0 ) {

                    $t_min = $h * 60 + $m;

                    if ( $t_min <= $exclude_at_or_before_min ) {

                        continue;

                    }

                }

                $t = sprintf( '%02d:%02d', $h, $m );

                if ( self::is_time_in_allowed_periods( $t, $allowed_periods ) ) {

                    $slots[] = $t;

                }

            }

        }

        return $slots;

    }



    /**

     * 時刻 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 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();

    }

}

