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



        // 旧実装は本クラスのクロージャと AIAP_Scheduler の同等処理が独立に存在していた。

        // AIAP_Scheduler::time_to_minutes() に集約済み (CLAUDE.md「同一処理の共通化」)。

        $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 = AIAP_Scheduler::time_to_minutes( $p['start'] );

            $e = AIAP_Scheduler::time_to_minutes( $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 ( AIAP_Compat::mb_strlen( $name ) > 60 ) {

            $name = AIAP_Compat::mb_substr( $name, 0, 60 );

        }



        $tasks   = self::get_all();

        $task_id = uniqid( 'task_', true );

        $tasks[] = array(

            'id'                  => $task_id,

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

        return $task_id;

    }



    /**

     * 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 ( AIAP_Compat::mb_strlen( $s ) > 30 ) {

            $s = AIAP_Compat::mb_substr( $s, 0, 30 ) . '…';

        }

        return $s;

    }



    public static function update( $id, $data ) {

        if ( is_array( $data ) ) {

            // featured_image_mode が含まれていれば正規化する('off' 以外はすべて 'on' に丸める)

            if ( array_key_exists( 'featured_image_mode', $data ) ) {

                $data['featured_image_mode'] = self::sanitize_image_mode( $data['featured_image_mode'] );

            }

            // Bug fix: 外部呼び出しで schedule を直接渡された場合、parse_schedule_from_form を

            // 経由しないと未正規化("9:00" 等の 1 桁 hour、重複、範囲外、空文字)が混入し、

            // スケジューラ側の文字列比較 ($time > $now_hi) が誤判定する。入口で必ず正規化する。

            if ( array_key_exists( 'schedule', $data ) && is_array( $data['schedule'] ) ) {

                $data['schedule'] = self::normalize_schedule( $data['schedule'] );

            }

        }

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

    }



    /**

     * 本日の投稿予定数を返す (固定スケジュールのスロット数 + ランダムタスクの今日の乱数決定値の合算)

     *

     * 同じ 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,

        );

    }



    /**

     * 本日(今日の曜日)に実行されるタスク数を数える。

     *

     * count_today_planned_posts() が「投稿予定件数」を返すのに対し、本メソッドは

     * 「タスク数」を返す。ダッシュボードの「本日予定されているタスク: N件」表示用。

     *

     * 固定スケジュールのタスクは schedule[today_key] に有効な時刻が 1 件でもあれば数える。

     * ランダムモードのタスクは今日分の生成時刻が 1 件以上あれば数える。

     * (旧実装は schedule[today_key] のみを見ていたためランダムタスクが除外され、

     *  ダッシュボードの「稼働中タスク」数と食い違う原因になっていた。)

     *

     * @return int 本日実行されるタスクの数

     */

    public static function count_tasks_scheduled_today() {

        $tasks     = self::get_all();

        $today_key = self::get_today_key();

        $count     = 0;



        foreach ( $tasks as $task ) {

            if ( empty( $task['enabled'] ) ) {

                continue;

            }

            if ( self::is_random_mode_active( $task ) ) {

                // ランダムモード: 今日分の生成時刻が 1 件以上あれば本日実行されるタスク。

                // get_random_times_for_today はキャッシュ済みなら即返す (二重生成にはならない)。

                if ( class_exists( 'AIAP_Scheduler' ) ) {

                    $times = AIAP_Scheduler::get_random_times_for_today( $task );

                    if ( is_array( $times ) && count( $times ) > 0 ) {

                        $count++;

                    }

                }

            } 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 ) ) {

                            $count++;

                            break; // タスク単位で 1 回数えれば十分

                        }

                    }

                }

            }

        }



        return $count;

    }



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



    /**

     * スケジュール配列を正規化（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 で破棄される不正な時刻の件数を数える。

     *

     * 「不正」= HH:MM 形式でないか、時/分の範囲外の値だけをカウントする。

     * 重複時刻 (例: "09:00, 09:00") は normalize で 1 件に集約されるが、ユーザーの

     * 入力ミスではないので "不正" にはカウントしない。末尾カンマ等の空トークンも除外。

     *

     * @param array $raw $_POST['schedule'] 由来の配列(wp_unslash 済み)

     * @return int 破棄された不正な時刻トークン数

     */

    public static function count_invalid_in_schedule_form( $raw ) {

        if ( ! is_array( $raw ) ) {

            return 0;

        }

        $invalid = 0;

        foreach ( self::get_day_keys() as $day ) {

            $value = isset( $raw[ $day ] ) && is_string( $raw[ $day ] ) ? $raw[ $day ] : '';

            $value = sanitize_text_field( $value );

            $value = trim( $value );

            if ( $value === '' ) {

                continue;

            }

            $tokens = array_filter( array_map( 'trim', explode( ',', $value ) ), 'strlen' );

            foreach ( $tokens as $token ) {

                if ( ! preg_match( '/^(\d{1,2}):(\d{2})$/', $token, $m ) ) {

                    $invalid++;

                    continue;

                }

                $h = (int) $m[1];

                $i = (int) $m[2];

                if ( $h < 0 || $h > 23 || $i < 0 || $i > 59 ) {

                    $invalid++;

                }

            }

        }

        return $invalid;

    }



}

