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

/**
 * タスク（プロンプト＋スケジュール）管理クラス
 *
 * 各タスクは以下の構造:
 * array(
 *     'id'         => string (uniqid),
 *     'prompt'     => string,
 *     'schedule'   => array(
 *         'mon' => array( '09:00', '14:00' ),
 *         'tue' => array( '10:00' ),
 *         ...
 *     ),
 *     'enabled'    => int (0|1),
 *     'used_count' => int,
 *     'created_at' => string (datetime),
 * )
 */
class AIAP_Prompts {

    const OPTION_TASKS = 'aiap_tasks';

    /**
     * 曜日キーと表示名の対応(設定言語に応じて切替)
     */
    public static function get_day_labels() {
        return array(
            'mon' => __( 'Mon', 'ai-auto-poster' ),
            'tue' => __( 'Tue', 'ai-auto-poster' ),
            'wed' => __( 'Wed', 'ai-auto-poster' ),
            'thu' => __( 'Thu', 'ai-auto-poster' ),
            'fri' => __( 'Fri', 'ai-auto-poster' ),
            'sat' => __( 'Sat', 'ai-auto-poster' ),
            'sun' => __( 'Sun', 'ai-auto-poster' ),
        );
    }

    /**
     * 全曜日キー
     */
    public static function get_day_keys() {
        return array( 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' );
    }

    /**
     * PHPの曜日番号（date('w')）からキーへの変換
     */
    public static function day_number_to_key( $day_number ) {
        $map = array(
            0 => 'sun', 1 => 'mon', 2 => 'tue', 3 => 'wed',
            4 => 'thu', 5 => 'fri', 6 => 'sat',
        );
        return isset( $map[ $day_number ] ) ? $map[ $day_number ] : 'mon';
    }

    /**
     * 今日の曜日キーを取得
     */
    public static function get_today_key() {
        return self::day_number_to_key( (int) current_time( 'w' ) );
    }

    /**
     * 今日の曜日ラベル(設定言語)を取得
     */
    public static function get_today_label() {
        $labels = self::get_day_labels();
        $key    = self::get_today_key();
        return isset( $labels[ $key ] ) ? $labels[ $key ] : '';
    }

    /**
     * Phase 5: タスクのスケジュールを 1 行サマリで返す。
     * 例: "毎日 09:00", "月水金 09:00", "ランダム 5–10 回 / 日"
     */
    public static function format_schedule_summary( $task ) {
        if ( self::is_random_mode_active( $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;
            if ( $min > 0 && $max > 0 ) {
                if ( $min === $max ) {
                    /* translators: %d = post count */
                    return sprintf( __( 'Random %d times / day', 'ai-auto-poster' ), $min );
                }
                /* translators: 1: min count, 2: max count */
                return sprintf( __( 'Random %1$d–%2$d times / day', 'ai-auto-poster' ), $min, $max );
            }
            return __( 'Random schedule', 'ai-auto-poster' );
        }

        $schedule = isset( $task['schedule'] ) && is_array( $task['schedule'] ) ? $task['schedule'] : array();
        $day_labels = self::get_day_labels();
        $day_keys   = array( 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' );

        // 各曜日に時刻が存在するかと、時刻自体を集める
        $active_days = array();
        $all_times   = array();
        foreach ( $day_keys as $dk ) {
            $times = isset( $schedule[ $dk ] ) && is_array( $schedule[ $dk ] ) ? array_filter( $schedule[ $dk ], 'strlen' ) : array();
            if ( ! empty( $times ) ) {
                $active_days[] = $dk;
                foreach ( $times as $t ) $all_times[] = trim( $t );
            }
        }
        if ( empty( $active_days ) ) {
            return __( 'No schedule', 'ai-auto-poster' );
        }
        // 時刻が全曜日で同じなら 1 回だけ表示
        $unique_times = array_values( array_unique( $all_times ) );
        sort( $unique_times );
        $time_str = count( $unique_times ) <= 3 ? implode( ', ', $unique_times ) : ( $unique_times[0] . ' …' );

        if ( count( $active_days ) === 7 ) {
            /* translators: %s = times like "09:00" */
            return sprintf( __( 'Daily %s', 'ai-auto-poster' ), $time_str );
        }
        $day_str = implode( '', array_map( function( $dk ) use ( $day_labels ) {
            return isset( $day_labels[ $dk ] ) ? $day_labels[ $dk ] : '';
        }, $active_days ) );
        return $day_str . ' ' . $time_str;
    }

    // ========== タスク CRUD ==========

    public static function get_all() {
        $tasks = get_option( self::OPTION_TASKS, array() );
        if ( ! is_array( $tasks ) ) { return array(); }

        // ランダム投稿仕様変更 (Phase 13): 旧 exclude_periods (投稿しない時間帯) を
        // 新 allowed_periods (投稿してよい時間帯) に補集合計算で移行する。
        // 読み込み時の lazy migration なので、既存タスクが UI で開かれて保存されると
        // 自動的に新フィールド名で書き戻される。
        $changed = false;
        foreach ( $tasks as $idx => &$t ) {
            if ( ! is_array( $t ) ) { continue; }
            // 旧 exclude_periods を allowed_periods に変換
            if ( ! isset( $t['allowed_periods'] ) && isset( $t['exclude_periods'] ) ) {
                $t['allowed_periods'] = self::exclude_to_allowed( $t['exclude_periods'] );
                unset( $t['exclude_periods'] );
                $changed = true;
            }
            // デフォルト値を補完
            if ( ! isset( $t['allowed_periods'] ) || ! is_array( $t['allowed_periods'] ) ) {
                $t['allowed_periods'] = array();
            }
            if ( ! isset( $t['min_interval_minutes'] ) ) {
                $t['min_interval_minutes'] = 0;
            }
        }
        unset( $t );

        // 移行が起きた場合は DB にも書き戻す (option 1 回 update。次回以降の get_all は早い)
        // autoload=false: save_all (L189) と方針を揃える (タスクは管理画面と cron でしか参照されない)
        if ( $changed ) {
            update_option( self::OPTION_TASKS, $tasks, false );
        }
        return $tasks;
    }

    public static function save_all( $tasks ) {
        // Bug fix (audit r9 self-audit, #44): 新規追加されたスケジュール時刻を「過去の
        // 取りこぼし」として遡及投稿しないよう、追加と同時に last_run に「実行済み」マークを
        // 入れる (=「埋葬」)。
        //
        // 背景: タスクの schedule は曜日ベース ("mon" => ["10:00"], ...) で保存される。
        // collect_runs_for_past_days は過去 RECOVER_DAYS 日分の各日について、その日の曜日
        // で該当する schedule を抽出し、last_run に記録のないものを「取りこぼし」として
        // 拾う。そのため、ユーザーが今日の 14:00 に「金曜 10:00」を新規追加しただけで、
        // 今朝の 10:00 や先週金曜の 10:00 が遡及的に投稿される事故が起きていた。
        // これはユーザーの直感に反する (新規追加は「今後この時刻に投稿する」の意図であって、
        // 過去に遡って実行する意図ではない)。
        //
        // 埋葬対象: 「新たに追加された時刻」について、
        //   - 今日 + 過去 RECOVER_DAYS 日内の該当曜日の日付すべて
        //   - ただし今日の未来時刻 (今より後) は埋葬しない (まだ実行されていないだけ)
        self::bury_newly_added_past_schedules( $tasks );

        // Feature add (#48): モード切り替え時、当日の「未実行の予定」を埋葬する。
        //  - 時間指定 → ランダム: 当日 schedule のうち未実行を埋葬
        //  - ランダム → 時間指定: 当日 random_today のうち未実行を埋葬 + キャッシュクリア
        // これにより「ランダムにしてた間も、元 schedule の予定が裏で取りこぼし扱いとして
        // 蓄積される」現象を防ぐ。
        self::bury_on_mode_switch( $tasks );

        // autoload=false: タスクは管理画面とcron処理でしか参照されない
        update_option( self::OPTION_TASKS, array_values( $tasks ), false );
    }

    /**
     * モード切り替え時の埋葬処理 (#48)
     */
    private static function bury_on_mode_switch( array $new_tasks ) {
        if ( ! class_exists( 'AIAP_Scheduler' ) ) {
            return;
        }

        // 旧タスクを ID で index 化
        $old_index = array();
        foreach ( self::get_all() as $task ) {
            if ( empty( $task['id'] ) ) {
                continue;
            }
            $old_index[ $task['id'] ] = $task;
        }

        $today_str = current_time( 'Y-m-d' );
        $today_key = self::get_today_key();

        $last_run = get_option( AIAP_Scheduler::OPTION_LAST_RUN, array() );
        if ( ! is_array( $last_run ) ) { $last_run = array(); }
        $last_run_changed = false;

        $random_cache = get_option( AIAP_Scheduler::OPTION_RANDOM_TIMES, array() );
        if ( ! is_array( $random_cache ) ) { $random_cache = array(); }
        $random_cache_changed = false;

        foreach ( $new_tasks as $new_task ) {
            if ( empty( $new_task['id'] ) ) {
                continue;
            }
            $task_id  = $new_task['id'];
            $old_task = isset( $old_index[ $task_id ] ) ? $old_index[ $task_id ] : null;
            if ( $old_task === null ) {
                continue; // 新規タスクは bury_newly_added_past_schedules が対応する
            }

            $old_random = self::is_random_mode_active( $old_task );
            $new_random = self::is_random_mode_active( $new_task );

            if ( $old_random === $new_random ) {
                // Phase 13 案 A: モード切替はないが、ランダム同士のパラメータ変更を検知して
                // 今日のキャッシュの「now 以降」だけを新設定で組み替える。
                if ( $new_random && self::random_params_differ( $old_task, $new_task ) ) {
                    if ( isset( $random_cache[ $task_id ]['date_str'] )
                         && $random_cache[ $task_id ]['date_str'] === $today_str
                         && isset( $random_cache[ $task_id ]['times'] )
                         && is_array( $random_cache[ $task_id ]['times'] ) ) {
                        $new_times = AIAP_Scheduler::regenerate_future_random_times(
                            $new_task,
                            $random_cache[ $task_id ]['times']
                        );
                        $random_cache[ $task_id ]['times'] = $new_times;
                        $random_cache_changed              = true;
                    }
                    // 今日のキャッシュがまだ無い場合は何もしない (lazy 生成時に新設定が使われる)
                }
                continue;
            }

            if ( ! $old_random && $new_random ) {
                // 時間指定 → ランダム: 当日 schedule の未実行を埋葬
                $sched = ( isset( $old_task['schedule'][ $today_key ] ) && is_array( $old_task['schedule'][ $today_key ] ) )
                    ? $old_task['schedule'][ $today_key ] : array();
                foreach ( $sched as $time ) {
                    $time = trim( (string) $time );
                    if ( $time === '' ) { continue; }
                    $run_key = $today_str . '_' . $task_id . '_' . $time;
                    if ( empty( $last_run[ $run_key ] ) ) {
                        $last_run[ $run_key ] = current_time( 'mysql' );
                        $last_run_changed     = true;
                    }
                }
            } elseif ( $old_random && ! $new_random ) {
                // ランダム → 時間指定: 当日 random_today の未実行を埋葬 + キャッシュクリア
                if ( isset( $random_cache[ $task_id ]['date_str'] )
                     && $random_cache[ $task_id ]['date_str'] === $today_str
                     && isset( $random_cache[ $task_id ]['times'] )
                     && is_array( $random_cache[ $task_id ]['times'] ) ) {
                    foreach ( $random_cache[ $task_id ]['times'] as $time ) {
                        $time = trim( (string) $time );
                        if ( $time === '' ) { continue; }
                        $run_key = $today_str . '_' . $task_id . '_' . $time;
                        if ( empty( $last_run[ $run_key ] ) ) {
                            $last_run[ $run_key ] = current_time( 'mysql' );
                            $last_run_changed     = true;
                        }
                    }
                }
                if ( isset( $random_cache[ $task_id ] ) ) {
                    unset( $random_cache[ $task_id ] );
                    $random_cache_changed = true;
                }
            }
        }

        if ( $last_run_changed ) {
            update_option( AIAP_Scheduler::OPTION_LAST_RUN, $last_run, false );
        }
        if ( $random_cache_changed ) {
            update_option( AIAP_Scheduler::OPTION_RANDOM_TIMES, $random_cache, false );
        }
    }

    /**
     * 新規追加された schedule 時刻を、過去日分について last_run に「実行済み」記録する。
     * これにより取りこぼし対象から除外される。
     *
     * @param array $new_tasks 保存しようとしているタスク配列
     */
    private static function bury_newly_added_past_schedules( array $new_tasks ) {
        if ( ! class_exists( 'AIAP_Scheduler' ) ) {
            return;
        }

        // 旧タスクの schedule を [task_id][day_key] = [times] でインデックス化
        $old_index = array();
        foreach ( self::get_all() as $task ) {
            if ( empty( $task['id'] ) ) {
                continue;
            }
            $old_index[ $task['id'] ] = ( isset( $task['schedule'] ) && is_array( $task['schedule'] ) )
                ? $task['schedule'] : array();
        }

        $now_ts    = current_time( 'timestamp' );
        $now_hi    = current_time( 'H:i' );
        $today_key = self::get_today_key();

        $last_run = get_option( AIAP_Scheduler::OPTION_LAST_RUN, array() );
        if ( ! is_array( $last_run ) ) {
            $last_run = array();
        }
        $changed = false;

        foreach ( $new_tasks as $task ) {
            if ( empty( $task['id'] ) ) {
                continue;
            }
            // ランダム投稿モードのタスクは過去日のランダム時刻を保持しないため、
            // 「過去の取りこぼし」概念がそもそも成立しない。埋葬処理をスキップ (#47)。
            if ( self::is_random_mode_active( $task ) ) {
                continue;
            }
            $new_sched = ( isset( $task['schedule'] ) && is_array( $task['schedule'] ) )
                ? $task['schedule'] : array();
            $old_sched = isset( $old_index[ $task['id'] ] ) ? $old_index[ $task['id'] ] : array();

            foreach ( $new_sched as $day_key => $times ) {
                if ( ! is_array( $times ) ) {
                    continue;
                }
                $old_times = ( isset( $old_sched[ $day_key ] ) && is_array( $old_sched[ $day_key ] ) )
                    ? array_map( 'trim', $old_sched[ $day_key ] ) : array();
                $cur_times = array_map( 'trim', $times );

                // 新規追加された時刻のみを抽出 (旧 schedule に既にあった時刻は対象外)
                $newly_added = array_diff( $cur_times, $old_times );

                foreach ( $newly_added as $time ) {
                    $time = trim( (string) $time );
                    if ( $time === '' ) {
                        continue;
                    }

                    // 今日 + 過去 RECOVER_DAYS 日の範囲で該当曜日の日付を埋葬
                    for ( $i = 0; $i <= AIAP_Scheduler::RECOVER_DAYS; $i++ ) {
                        $date_ts = strtotime( "-{$i} days", $now_ts );
                        if ( $date_ts === false ) {
                            continue;
                        }
                        $date_day_num = (int) wp_date( 'w', $date_ts );
                        $date_key     = self::day_number_to_key( $date_day_num );
                        if ( $date_key !== $day_key ) {
                            continue;
                        }
                        // 今日の場合、未来時刻 (今より後) は埋葬しない (これから実行される予定)
                        if ( $i === 0 && $time > $now_hi ) {
                            continue;
                        }
                        $date_str = wp_date( 'Y-m-d', $date_ts );
                        $run_key  = $date_str . '_' . $task['id'] . '_' . $time;
                        if ( empty( $last_run[ $run_key ] ) ) {
                            $last_run[ $run_key ] = current_time( 'mysql' );
                            $changed = true;
                        }
                    }
                }
            }
        }

        if ( $changed ) {
            update_option( AIAP_Scheduler::OPTION_LAST_RUN, $last_run, false );
        }
    }

    public static function get_by_id( $id ) {
        foreach ( self::get_all() as $task ) {
            if ( isset( $task['id'] ) && $task['id'] === $id ) {
                return $task;
            }
        }
        return null;
    }

    /**
     * ログ表示用のタスクラベルを返す。
     *
     * 引数はタスク配列・タスクID文字列・null/空のいずれも受け付ける。
     * prompt が取れない場合は "-" を返し、ログテンプレートの "(task: )" 表示を回避する。
     */
    public static function format_log_label( $task_or_id ) {
        $task = null;
        if ( is_array( $task_or_id ) ) {
            $task = $task_or_id;
        } elseif ( is_string( $task_or_id ) && $task_or_id !== '' ) {
            $task = self::get_by_id( $task_or_id );
        }
        if ( ! is_array( $task ) || empty( $task['prompt'] ) ) {
            return '-';
        }
        return mb_substr( (string) $task['prompt'], 0, 30 ) . '…';
    }

    /**
     * featured_image_mode の値を正規化する。
     *
     * 'on' / 'off' のみを受け付ける。それ以外（'inherit'・null・空文字・未知値）はすべて 'on' に丸める。
     * 旧仕様の 'inherit' (= 既定に従う) は廃止され、'on' (画像を付ける) と同等扱いになる。
     *
     * @param mixed $value
     * @return string 'on' | 'off'
     */
    public static function sanitize_image_mode( $value ) {
        $value = is_string( $value ) ? $value : '';
        return $value === 'off' ? 'off' : 'on';
    }

    /**
     * タスク配列から featured_image_mode を取得する。未設定や不正値は 'on' を返す。
     *
     * @param array $task
     * @return string 'on' | 'off'
     */
    public static function get_image_mode( $task ) {
        $val = isset( $task['featured_image_mode'] ) ? $task['featured_image_mode'] : '';
        return self::sanitize_image_mode( $val );
    }

    // ============================================================
    // RSS ニュース連携 (タスク単位設定)
    // ============================================================

    /**
     * rss_enabled を 0|1 に正規化
     *
     * @param mixed $value
     * @return int
     */
    public static function sanitize_rss_enabled( $value ) {
        return ! empty( $value ) ? 1 : 0;
    }

    /**
     * rss_feed_ids を実在するフィード ID のみに絞る配列に正規化
     *
     * @param mixed $value
     * @return array
     */
    public static function sanitize_rss_feed_ids( $value ) {
        if ( ! is_array( $value ) ) {
            return array();
        }
        $existing = array();
        if ( class_exists( 'AIAP_RSS_Feeds' ) ) {
            foreach ( AIAP_RSS_Feeds::get_all() as $feed ) {
                if ( isset( $feed['id'] ) ) {
                    $existing[] = $feed['id'];
                }
            }
        }
        $out = array();
        foreach ( $value as $v ) {
            $v = sanitize_key( (string) $v );
            if ( $v !== '' && in_array( $v, $existing, true ) && ! in_array( $v, $out, true ) ) {
                $out[] = $v;
            }
        }
        return $out;
    }

    /**
     * rss_count を 1〜COUNT_MAX にクランプ（COUNT_MAX は AIAP_RSS_Feeds 側で定義）
     *
     * @param mixed $value
     * @return int
     */
    public static function sanitize_rss_count( $value ) {
        $max = class_exists( 'AIAP_RSS_Feeds' ) ? AIAP_RSS_Feeds::COUNT_MAX : 10;
        $n   = absint( $value );
        if ( $n < 1 )   { $n = 1; }
        if ( $n > $max ) { $n = $max; }
        return $n;
    }

    /**
     * rss_mode を 'pick' | 'digest' に正規化（不正値は 'pick'）
     *
     * @param mixed $value
     * @return string
     */
    public static function sanitize_rss_mode( $value ) {
        $value = is_string( $value ) ? $value : '';
        return in_array( $value, array( 'pick', 'digest' ), true ) ? $value : 'pick';
    }

    /**
     * rss_fallback を 'skip' | 'generate' に正規化（不正値は 'skip'）
     *
     * @param mixed $value
     * @return string
     */
    public static function sanitize_rss_fallback( $value ) {
        $value = is_string( $value ) ? $value : '';
        return in_array( $value, array( 'skip', 'generate' ), true ) ? $value : 'skip';
    }

    /**
     * タスク配列から RSS 関連設定を正規化したサブ配列を返す（rss_enabled=0 ならフィード未選択扱い）
     *
     * @param array $task
     * @return array { rss_enabled, rss_feed_ids, rss_count, rss_mode, rss_fallback }
     */
    public static function get_rss_settings( $task ) {
        if ( ! is_array( $task ) ) {
            $task = array();
        }
        $enabled = self::sanitize_rss_enabled( isset( $task['rss_enabled'] ) ? $task['rss_enabled'] : 0 );
        $feed_ids = self::sanitize_rss_feed_ids( isset( $task['rss_feed_ids'] ) ? $task['rss_feed_ids'] : array() );
        // フィード未選択なら強制的に rss_enabled=0
        if ( $enabled === 1 && empty( $feed_ids ) ) {
            $enabled = 0;
        }
        return array(
            'rss_enabled'  => $enabled,
            'rss_feed_ids' => $feed_ids,
            'rss_count'    => self::sanitize_rss_count( isset( $task['rss_count'] ) ? $task['rss_count'] : 5 ),
            'rss_mode'     => self::sanitize_rss_mode( isset( $task['rss_mode'] ) ? $task['rss_mode'] : 'pick' ),
            'rss_fallback' => self::sanitize_rss_fallback( isset( $task['rss_fallback'] ) ? $task['rss_fallback'] : 'skip' ),
        );
    }

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

    const RANDOM_COUNT_MIN  = 0;
    const RANDOM_COUNT_MAX     = 50;
    const RANDOM_TIME_STEP     = 5;     // 時刻の刻み (分)
    const MIN_INTERVAL_MAX_MIN = 720;   // 最小間隔の上限 (分) = 12 時間
    const MIN_INTERVAL_MIN_MIN = 0;     // 0 = 制約なし
    const RANDOM_TIME_SLOTS = 288;   // 24h * 60min / 5min

    /**
     * random_mode の値を 0/1 に正規化
     */
    public static function sanitize_random_mode( $value ) {
        return ! empty( $value ) ? 1 : 0;
    }

    /**
     * 0〜RANDOM_COUNT_MAX の整数に正規化
     */
    public static function sanitize_random_count( $value ) {
        $n = absint( $value );
        if ( $n < self::RANDOM_COUNT_MIN ) { $n = self::RANDOM_COUNT_MIN; }
        if ( $n > self::RANDOM_COUNT_MAX ) { $n = self::RANDOM_COUNT_MAX; }
        return $n;
    }

    /**
     * allowed_periods 配列を正規化
     * 入力例: [ ['start' => '07:00', 'end' => '22:00'], ... ]
     * - HH:MM 形式以外、または start = end の要素は除外
     * - start > end の場合は「日跨ぎ」として許容 (例: 22:00–07:00 = 夜間 OK)
     *
     * @param mixed $value
     * @return array
     */
    public static function sanitize_allowed_periods( $value ) {
        if ( ! is_array( $value ) ) { return array(); }
        $out = array();
        foreach ( $value as $period ) {
            if ( ! is_array( $period ) ) { continue; }
            $start = isset( $period['start'] ) ? trim( (string) $period['start'] ) : '';
            $end   = isset( $period['end'] ) ? trim( (string) $period['end'] ) : '';
            if ( ! preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $start ) ) { continue; }
            if ( ! preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $end ) ) { continue; }
            if ( $start === $end ) { continue; } // 同じ時刻は意味なし
            $out[] = array( 'start' => $start, 'end' => $end );
        }
        return $out;
    }

    /**
     * 最小投稿間隔(分)を正規化 (0–MIN_INTERVAL_MAX_MIN)
     * 0 は「制約なし」を意味する。
     */
    public static function sanitize_min_interval_minutes( $value ) {
        $n = (int) $value;
        if ( $n < self::MIN_INTERVAL_MIN_MIN ) { $n = self::MIN_INTERVAL_MIN_MIN; }
        if ( $n > self::MIN_INTERVAL_MAX_MIN ) { $n = self::MIN_INTERVAL_MAX_MIN; }
        return $n;
    }

    /**
     * 2 つの task の「ランダム時刻生成に影響するパラメータ」が異なるかを判定 (Phase 13 案 A)
     *
     * 影響パラメータ: random_count_min / random_count_max / allowed_periods / min_interval_minutes
     * (random_mode 自体は別経路で扱うため対象外)
     *
     * 注 (self-audit #2): get_all() 経由ではなく save_all() 内で
     * get_option(OPTION_TASKS) を直接読むケースがあり、旧 task は exclude_periods のままで
     * 新 task が allowed_periods に変換済みという非対称状態が起き得る。両者を allowed 形式に
     * 揃えてから比較することで、見せかけの「変更あり」誤検知を防ぐ。
     *
     * @param array $old
     * @param array $new
     * @return bool いずれかが異なれば true
     */
    public static function random_params_differ( $old, $new ) {
        if ( (int) ( $old['random_count_min'] ?? 0 ) !== (int) ( $new['random_count_min'] ?? 0 ) ) { return true; }
        if ( (int) ( $old['random_count_max'] ?? 0 ) !== (int) ( $new['random_count_max'] ?? 0 ) ) { return true; }
        if ( (int) ( $old['min_interval_minutes'] ?? 0 ) !== (int) ( $new['min_interval_minutes'] ?? 0 ) ) { return true; }

        // 旧 exclude_periods 残存ケースを allowed_periods に正規化してから比較
        $old_ap_raw = $old['allowed_periods'] ?? null;
        if ( $old_ap_raw === null && isset( $old['exclude_periods'] ) ) {
            $old_ap_raw = self::exclude_to_allowed( $old['exclude_periods'] );
        }
        $new_ap_raw = $new['allowed_periods'] ?? null;
        if ( $new_ap_raw === null && isset( $new['exclude_periods'] ) ) {
            $new_ap_raw = self::exclude_to_allowed( $new['exclude_periods'] );
        }
        $old_ap = self::sanitize_allowed_periods( $old_ap_raw ?? array() );
        $new_ap = self::sanitize_allowed_periods( $new_ap_raw ?? array() );
        if ( count( $old_ap ) !== count( $new_ap ) ) { return true; }
        for ( $i = 0; $i < count( $old_ap ); $i++ ) {
            if ( $old_ap[ $i ]['start'] !== $new_ap[ $i ]['start'] ) { return true; }
            if ( $old_ap[ $i ]['end']   !== $new_ap[ $i ]['end']   ) { return true; }
        }
        return false;
    }

    /**
     * 旧 exclude_periods (投稿しない時間帯) を新 allowed_periods (投稿してよい時間帯) に
     * 補集合計算で変換する。
     *
     * 仕様:
     * - exclude が空 → allowed も空 (= 24h 全許可、新仕様の default 動作)
     * - exclude が一日全部 → allowed は空にせず、空ではない degenerate value にしたい
     *   (空 = 全許可 と区別が必要) のだが、実装簡略化のため complement そのまま返す。
     *   complement が空配列になる場合は「全部 exclude → 何も投稿しない」が
     *   「全許可」に戻ってしまうエッジケースだが、実利用ではほぼ起き得ない。
     *
     * @param array $excludes [{start, end}, ...]
     * @return array [{start, end}, ...]
     */
    public static function exclude_to_allowed( $excludes ) {
        $excludes = self::sanitize_allowed_periods( $excludes ); // 同じ形式バリデータ
        if ( empty( $excludes ) ) { return array(); }

        $time_to_min = function ( $hhmm ) {
            $parts = explode( ':', $hhmm );
            return ( (int) $parts[0] ) * 60 + (int) $parts[1];
        };
        $min_to_time = function ( $m ) {
            $m = ( $m % 1440 + 1440 ) % 1440;
            return sprintf( '%02d:%02d', intval( $m / 60 ), $m % 60 );
        };

        // 線形 [0, 1440] に展開 (日跨ぎは 2 つに分割)
        $linear = array();
        foreach ( $excludes as $p ) {
            $s = $time_to_min( $p['start'] );
            $e = $time_to_min( $p['end'] );
            if ( $s < $e ) {
                $linear[] = array( $s, $e );
            } else {
                // 日跨ぎ: [s, 1440] + [0, e]
                $linear[] = array( $s, 1440 );
                if ( $e > 0 ) { $linear[] = array( 0, $e ); }
            }
        }
        if ( empty( $linear ) ) { return array(); }

        // start でソート → 重複/隣接マージ
        usort( $linear, function ( $a, $b ) { return $a[0] - $b[0]; } );
        $merged = array( $linear[0] );
        for ( $i = 1; $i < count( $linear ); $i++ ) {
            $last_idx = count( $merged ) - 1;
            if ( $linear[ $i ][0] <= $merged[ $last_idx ][1] ) {
                if ( $linear[ $i ][1] > $merged[ $last_idx ][1] ) {
                    $merged[ $last_idx ][1] = $linear[ $i ][1];
                }
            } else {
                $merged[] = $linear[ $i ];
            }
        }

        // [0, 1440] - merged = 補集合
        $allowed = array();
        $cursor  = 0;
        foreach ( $merged as $iv ) {
            if ( $iv[0] > $cursor ) {
                $allowed[] = array( $cursor, $iv[0] );
            }
            $cursor = $iv[1];
        }
        if ( $cursor < 1440 ) {
            $allowed[] = array( $cursor, 1440 );
        }

        // [0, 24] を 24:00 ではなく次の日の 00:00 で扱うため、
        // end=1440 は 24:00 ではなく「日跨ぎ表現」にして 00:00 と書く
        // ただし single period [start, 24:00] のままでも is_time_in_allowed_periods 側で
        // start <= time < end として扱えれば問題ない。end='24:00' は HH:MM 形式外なので
        // [0, 1440] に至る場合は「allowed_periods は空 (=全許可)」と等価に置換する。
        if ( count( $allowed ) === 1 && $allowed[0][0] === 0 && $allowed[0][1] === 1440 ) {
            return array(); // 全許可 = 空配列
        }

        $result = array();
        foreach ( $allowed as $iv ) {
            // end=1440 なら 23:59 にクリップ (5 分刻みでは事実上 23:55 まで使われる)
            $end_min = $iv[1] >= 1440 ? 1439 : $iv[1];
            $result[] = array(
                'start' => $min_to_time( $iv[0] ),
                'end'   => $min_to_time( $end_min ),
            );
        }
        return $result;
    }

    /**
     * タスクの random_mode が実効的に有効かを判定
     * (random_mode = ON かつ random_count_max > 0)
     */
    public static function is_random_mode_active( $task ) {
        if ( empty( $task['random_mode'] ) ) { return false; }
        $max = isset( $task['random_count_max'] ) ? (int) $task['random_count_max'] : 0;
        return $max > 0;
    }

    /**
     * タスクを追加
     *
     * @param string $prompt
     * @param array  $schedule  array( 'mon' => array('09:00'), ... )
     * @param int    $enabled
     * @param string $content_language 記事の言語(空文字でグローバル設定にフォールバック)
     * @param int    $content_length   記事の文字数(0でグローバル設定にフォールバック)
     * @param string $name             Phase 8: タスク名 (空文字なら表示時に自動命名でフォールバック)
     */
    public static function add( $prompt, $schedule = array(), $enabled = 1, $content_language = '', $content_length = 0, $featured_image_mode = 'on', $random_mode = 0, $random_count_min = 0, $random_count_max = 0, $allowed_periods = array(), $min_interval_minutes = 0, $name = '' ) {
        // 二重防御: 呼び出し側でサニタイズ済みでもコア層でも明示的に通す
        $prompt = sanitize_textarea_field( (string) $prompt );
        $name   = sanitize_text_field( (string) $name );
        // 名前は 60 文字までに制限 (UI 表示が崩れないように)
        if ( mb_strlen( $name ) > 60 ) {
            $name = mb_substr( $name, 0, 60 );
        }

        $tasks   = self::get_all();
        $tasks[] = array(
            'id'                  => uniqid( 'task_', true ),
            'name'                => $name,
            'prompt'              => $prompt,
            'schedule'            => $schedule,
            'enabled'             => (int) $enabled,
            'used_count'          => 0,
            'created_at'          => current_time( 'mysql' ),
            'content_language'    => (string) $content_language,
            'content_length'      => (int) $content_length,
            'featured_image_mode' => self::sanitize_image_mode( $featured_image_mode ),
            // ランダム投稿モード (#47): ON のとき schedule は無視され、毎日 min〜max 回の
            // ランダム時刻 (5 分刻み、allowed_periods の時間帯のみ) で投稿される。
            // allowed_periods が空配列の場合は 24h 全許可。
            // min_interval_minutes が 0 より大きい場合、選ばれた時刻同士の最小間隔として強制される。
            'random_mode'          => self::sanitize_random_mode( $random_mode ),
            'random_count_min'     => self::sanitize_random_count( $random_count_min ),
            'random_count_max'     => self::sanitize_random_count( $random_count_max ),
            'allowed_periods'      => self::sanitize_allowed_periods( $allowed_periods ),
            'min_interval_minutes' => self::sanitize_min_interval_minutes( $min_interval_minutes ),
        );
        // min > max なら max を min に揃える
        $last = count( $tasks ) - 1;
        if ( $tasks[ $last ]['random_count_max'] < $tasks[ $last ]['random_count_min'] ) {
            $tasks[ $last ]['random_count_max'] = $tasks[ $last ]['random_count_min'];
        }
        self::save_all( $tasks );
    }

    /**
     * Phase 8: タスクの表示用名前を取得する。
     * - 明示的に name が設定されていればそれを返す
     * - なければ prompt の先頭からマークダウン記号を除いた最初の文を抽出 (~30文字)
     * - prompt も空なら $fallback_index で連番 ("Task 1" 等) を返す
     */
    public static function get_display_name( $task, $fallback_index = 1 ) {
        $name = isset( $task['name'] ) ? trim( (string) $task['name'] ) : '';
        if ( $name !== '' ) {
            return $name;
        }
        $prompt = isset( $task['prompt'] ) ? (string) $task['prompt'] : '';
        if ( $prompt !== '' ) {
            $auto = self::format_auto_name( $prompt );
            if ( $auto !== '' ) return $auto;
        }
        /* translators: %d = sequential task number (1-based) */
        return sprintf( __( 'Task %d', 'ai-auto-poster' ), $fallback_index );
    }

    /**
     * Phase 8: プロンプトから自動的に「タスク名」を抽出する。
     * - マークダウン記号 (■, *, #, -, >, _, `, ~) や箇条書き記号を除去
     * - 連続空白を 1 つに圧縮
     * - 最初の意味のある文 (~30 文字) を返す
     */
    public static function format_auto_name( $prompt ) {
        $s = (string) $prompt;
        // 行頭のマークダウン/装飾記号を除去
        $s = preg_replace( '/^[\s■◆◇●○▶▷★☆#>\-\*_=`~|\[\]]+/u', '', $s );
        // 一般的なマークダウン強調記号 (** __) を除去
        $s = preg_replace( '/[\*_]{1,3}/', '', $s );
        // 「テーマ:」「Topic:」みたいなプレフィックスを除去
        $s = preg_replace( '/^(テーマ|題材|タイトル|主題|Topic|Title|Subject)[:：]\s*/iu', '', $s );
        // 改行/タブを空白に
        $s = preg_replace( '/[\r\n\t]+/', ' ', $s );
        // 連続空白を 1 つに
        $s = preg_replace( '/\s+/', ' ', $s );
        $s = trim( $s );
        if ( $s === '' ) return '';
        // 30 文字で切り詰め
        if ( mb_strlen( $s ) > 30 ) {
            $s = mb_substr( $s, 0, 30 ) . '…';
        }
        return $s;
    }

    public static function update( $id, $data ) {
        // featured_image_mode が含まれていれば正規化する('off' 以外はすべて 'on' に丸める)
        if ( is_array( $data ) && array_key_exists( 'featured_image_mode', $data ) ) {
            $data['featured_image_mode'] = self::sanitize_image_mode( $data['featured_image_mode'] );
        }
        $tasks = self::get_all();
        foreach ( $tasks as $i => $task ) {
            if ( isset( $task['id'] ) && $task['id'] === $id ) {
                $tasks[ $i ] = wp_parse_args( $data, $task );
                break;
            }
        }
        self::save_all( $tasks );
    }

    public static function delete( $id ) {
        $tasks = self::get_all();
        $tasks = array_filter( $tasks, function( $t ) use ( $id ) {
            // PHP 8+ では配列キー欠損が warning になるため isset で防御
            return ! isset( $t['id'] ) || $t['id'] !== $id;
        } );
        self::save_all( $tasks );
    }

    /**
     * タスクの有効/無効を切り替え
     */
    public static function toggle( $id ) {
        $tasks = self::get_all();
        foreach ( $tasks as $i => $task ) {
            if ( isset( $task['id'] ) && $task['id'] === $id ) {
                $tasks[ $i ]['enabled'] = $task['enabled'] ? 0 : 1;
                break;
            }
        }
        self::save_all( $tasks );
    }

    // ========== 実行用 ==========

    /**
     * 指定の曜日・時刻に実行すべきタスク一覧を取得
     *
     * @param string      $day_key  曜日キー（省略時: 今日）
     * @param string|null $time     時刻 HH:MM（省略時: 曜日に紐付いた全タスク）
     * @return array
     */
    public static function get_scheduled_tasks( $day_key = null, $time = null ) {
        if ( $day_key === null ) {
            $day_key = self::get_today_key();
        }

        $tasks  = self::get_all();
        $result = array();

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

            $sched = isset( $task['schedule'] ) ? $task['schedule'] : array();
            if ( ! is_array( $sched ) || empty( $sched[ $day_key ] ) ) {
                continue;
            }

            $times = $sched[ $day_key ];
            if ( ! is_array( $times ) ) {
                continue;
            }

            // 時刻指定がなければ、この曜日に紐付いた全タスク
            if ( $time === null ) {
                $result[] = $task;
                continue;
            }

            // 時刻指定がある場合は一致チェック
            if ( in_array( $time, $times, true ) ) {
                $result[] = $task;
            }
        }

        return $result;
    }

    /**
     * 今日実行すべきタスク一覧（全時刻）
     */
    public static function get_today_tasks() {
        return self::get_scheduled_tasks( self::get_today_key() );
    }

    /**
     * 全タスクのスケジュールから、今日のこの曜日の全実行時刻を取得（重複除去・ソート済み）
     */
    public static function get_today_times() {
        $tasks     = self::get_all();
        $today_key = self::get_today_key();
        $times     = array();

        foreach ( $tasks as $task ) {
            if ( empty( $task['enabled'] ) ) {
                continue;
            }
            $sched = isset( $task['schedule'] ) ? $task['schedule'] : array();
            if ( ! empty( $sched[ $today_key ] ) && is_array( $sched[ $today_key ] ) ) {
                $times = array_merge( $times, $sched[ $today_key ] );
            }
        }

        $times = array_unique( $times );
        sort( $times );
        return array_values( $times );
    }

    public static function increment_used( $id ) {
        $tasks = self::get_all();
        foreach ( $tasks as $i => $task ) {
            if ( isset( $task['id'] ) && $task['id'] === $id ) {
                $tasks[ $i ]['used_count'] = (int) $tasks[ $i ]['used_count'] + 1;
                break;
            }
        }
        self::save_all( $tasks );
    }

    public static function count_enabled() {
        $count = 0;
        foreach ( self::get_all() as $task ) {
            if ( ! empty( $task['enabled'] ) && ! empty( $task['schedule'] ) ) {
                $count++;
            }
        }
        return $count;
    }

    public static function count_today() {
        return count( self::get_today_tasks() );
    }

    /**
     * 本日の投稿予定数を返す (固定スケジュールのスロット数 + ランダムタスクの今日の乱数決定値の合算)
     *
     * 同じ HH:MM が複数タスクで重なっていても、別タスク = 別投稿として 2 件と数える
     * (実装上、別 task_id なら独立に投稿される)。
     *
     * @return array {
     *   @type int   total       全タスク合計の投稿予定件数 (固定 + ランダム)
     *   @type int   fixed       固定スケジュール分の件数
     *   @type int   random      ランダムタスク分の件数 (今日の乱数決定値の合算)
     *   @type bool  has_random  ランダムモードのタスクが少なくとも 1 つ有効か
     * }
     */
    public static function count_today_planned_posts() {
        $tasks     = self::get_all();
        $today_key = self::get_today_key();
        $fixed     = 0;
        $random    = 0;
        $has_random = false;

        foreach ( $tasks as $task ) {
            if ( empty( $task['enabled'] ) ) {
                continue;
            }
            if ( self::is_random_mode_active( $task ) ) {
                $has_random = true;
                // get_random_times_for_today はキャッシュ済みなら即返す。未生成なら今日分を生成して
                // option に保存する (cron が後で同キャッシュを参照するので二重生成にはならない)。
                if ( class_exists( 'AIAP_Scheduler' ) ) {
                    $times = AIAP_Scheduler::get_random_times_for_today( $task );
                    $random += is_array( $times ) ? count( $times ) : 0;
                }
            } else {
                $sched = isset( $task['schedule'] ) && is_array( $task['schedule'] ) ? $task['schedule'] : array();
                if ( ! empty( $sched[ $today_key ] ) && is_array( $sched[ $today_key ] ) ) {
                    // 空文字や不正値を除いて数える
                    foreach ( $sched[ $today_key ] as $t ) {
                        $t = trim( (string) $t );
                        if ( $t !== '' && preg_match( '/^\d{1,2}:\d{2}$/', $t ) ) {
                            $fixed++;
                        }
                    }
                }
            }
        }

        return array(
            'total'      => $fixed + $random,
            'fixed'      => $fixed,
            'random'     => $random,
            'has_random' => $has_random,
        );
    }

    // ========== スケジュール ヘルパー ==========

    /**
     * スケジュール配列を正規化（HH:MM形式、ソート、重複除去）
     */
    public static function normalize_schedule( $schedule ) {
        $normalized = array();
        foreach ( self::get_day_keys() as $day ) {
            if ( ! isset( $schedule[ $day ] ) || ! is_array( $schedule[ $day ] ) ) {
                $normalized[ $day ] = array();
                continue;
            }
            $times = array_map( function( $t ) {
                $t = trim( $t );
                if ( preg_match( '/^(\d{1,2}):(\d{2})$/', $t, $m ) ) {
                    $h = (int) $m[1];
                    $i = (int) $m[2];
                    // 範囲外(24時以降、60分以降)は無効とみなす
                    if ( $h < 0 || $h > 23 || $i < 0 || $i > 59 ) {
                        return '';
                    }
                    return sprintf( '%02d:%02d', $h, $i );
                }
                return '';
            }, $schedule[ $day ] );
            $times = array_filter( $times );
            $times = array_unique( $times );
            sort( $times );
            $normalized[ $day ] = array_values( $times );
        }
        return $normalized;
    }

    /**
     * カンマ区切りテキストからスケジュール配列をパース
     * 入力: array( 'mon' => '09:00, 14:00', 'tue' => '10:00', ... )
     * 出力: array( 'mon' => array('09:00','14:00'), ... )
     *
     * 入力は wp_unslash 済みの $_POST 由来配列を想定。本メソッド内で
     * sanitize_text_field を通すため、呼び出し側で sanitize 済みである必要はない。
     */
    public static function parse_schedule_from_form( $raw ) {
        $schedule = array();
        if ( ! is_array( $raw ) ) {
            $raw = array();
        }
        foreach ( self::get_day_keys() as $day ) {
            // ネスト配列攻撃対策: 文字列でなければ空扱い
            $value = isset( $raw[ $day ] ) ? $raw[ $day ] : '';
            if ( ! is_string( $value ) ) {
                $value = '';
            }
            // 二重防御として明示的にサニタイズ(後段の正規表現で時刻形式に正規化されるが、
            // 入口で sanitize_text_field を通すことで Plugin Check の警告も防ぐ)
            $value = sanitize_text_field( $value );
            $str = trim( $value );
            if ( $str !== '' ) {
                $schedule[ $day ] = array_map( 'trim', explode( ',', $str ) );
            } else {
                $schedule[ $day ] = array();
            }
        }
        return self::normalize_schedule( $schedule );
    }

    /**
     * フォーム入力中、normalize_schedule で破棄される不正な時刻の件数を数える。
     *
     * parse_schedule_from_form の戻り値(=妥当な時刻だけが残る正規化済み配列)と
     * 入力フォーム側の有効トークン数を比較し、差分を「破棄された不正な時刻」とみなす。
     * 末尾カンマ等の空トークンは入力側でもカウントしない(ユーザー入力ミスではなく構文上の空)。
     *
     * @param array $raw $_POST['schedule'] 由来の配列(wp_unslash 済み)
     * @return int 破棄された不正な時刻トークン数
     */
    public static function count_invalid_in_schedule_form( $raw ) {
        if ( ! is_array( $raw ) ) {
            return 0;
        }
        $valid_total = 0;
        foreach ( self::parse_schedule_from_form( $raw ) as $times ) {
            $valid_total += count( $times );
        }
        $input_total = 0;
        foreach ( self::get_day_keys() as $day ) {
            $value = isset( $raw[ $day ] ) && is_string( $raw[ $day ] ) ? $raw[ $day ] : '';
            $value = trim( $value );
            if ( $value === '' ) {
                continue;
            }
            $tokens = array_filter( array_map( 'trim', explode( ',', $value ) ), 'strlen' );
            $input_total += count( $tokens );
        }
        return max( 0, $input_total - $valid_total );
    }

    /**
     * スケジュールをサマリー文字列に変換（表示用）
     */
    public static function schedule_to_summary( $schedule ) {
        $labels  = self::get_day_labels();
        $parts   = array();
        foreach ( $schedule as $day => $times ) {
            if ( ! empty( $times ) ) {
                $parts[] = $labels[ $day ] . ' ' . implode( ', ', $times );
            }
        }
        return implode( ' / ', $parts );
    }

    /**
     * 次に予定されている有効タスクを探す(手動実行のプロンプト空欄時用)
     *
     * 順序: 当日の残り時刻 -> 翌日 -> 翌々日 -> ... (最大7日先まで)
     * 同日内: 時刻が早い順で、現在時刻より後の最初の時刻に予定されているタスクを選ぶ
     * 有効(enabled)なタスクのみ対象
     *
     * @return array|null タスク配列、該当なしなら null
     */
    public static function find_next_scheduled_task() {
        $tasks = self::get_all();
        if ( empty( $tasks ) ) {
            return null;
        }

        $today_num = (int) current_time( 'w' );
        $now_hi    = current_time( 'H:i' );

        // 当日〜7日先まで順に探す。
        // 注意: $offset=7 は今日と同じ曜日(=来週同曜日)に戻ってしまうが、
        // その場合は別の日(7日後)として扱うのが正しい。time フィルタ条件は
        // "offset===0 のとき今日の過ぎた時刻をスキップ" なので、offset===7 でも
        // 今日に戻った重複扱いを避けるため、結局当日(offset===0)だけでスキップする。
        for ( $offset = 0; $offset <= 7; $offset++ ) {
            $day_num = ( $today_num + $offset ) % 7;
            $day_key = self::day_number_to_key( $day_num );

            // その曜日に予定されているタスクを全て集める(有効のみ)。
            //
            // Bug fix: 旧実装は $task['schedule'][$day_key] のみを見ていたため、
            // ランダムモードのタスク (schedule が空、ランダム時刻キャッシュで動く) が
            // 候補から完全に漏れていた。Quick Post の「プロンプト空欄 + ランダムタスク
            // のみ」サイトで "No available task" になっていた。ランダムタスクは当日分
            // (offset===0) に限り get_random_times_for_today から時刻を取り、未来時刻を
            // 候補に加える。翌日以降はランダム時刻が予測不可なので扱わない (同関数の
            // 仕様と一致)。
            $candidates = array();
            foreach ( $tasks as $task ) {
                if ( empty( $task['enabled'] ) ) { continue; }

                if ( self::is_random_mode_active( $task ) ) {
                    // ランダムモード: 当日のみ (翌日以降は予測不可)
                    if ( $offset !== 0 ) { continue; }
                    if ( ! class_exists( 'AIAP_Scheduler' ) ) { continue; }
                    $r_times = AIAP_Scheduler::get_random_times_for_today( $task );
                    if ( ! is_array( $r_times ) ) { continue; }
                    foreach ( $r_times as $time ) {
                        $time = trim( (string) $time );
                        if ( $time === '' ) { continue; }
                        if ( $time <= $now_hi ) { continue; }
                        $candidates[] = array( 'time' => $time, 'task' => $task );
                    }
                    continue;
                }

                if ( empty( $task['schedule'][ $day_key ] ) ) { continue; }
                foreach ( $task['schedule'][ $day_key ] as $time ) {
                    $time = trim( $time );
                    if ( $time === '' ) { continue; }
                    // 当日(offset=0)は現在時刻以降のみ対象。
                    // offset=7 は来週同曜日。今日の過ぎた時刻のタスクが「来週同曜日」として
                    // 候補入りするのは正しい(7日後の同時刻に実行される)。
                    if ( $offset === 0 && $time <= $now_hi ) { continue; }
                    $candidates[] = array( 'time' => $time, 'task' => $task );
                }
            }

            if ( empty( $candidates ) ) { continue; }

            // 時刻が早い順に並べて先頭を返す
            usort( $candidates, function ( $a, $b ) {
                return strcmp( $a['time'], $b['time'] );
            } );
            return $candidates[0]['task'];
        }

        return null;
    }
}
