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

/**
 * タスク → AI生成 → WordPress投稿 の一連の処理
 */
class AIAP_Poster {

    /**
     * 1タスクあたりの AI 呼び出し回数上限(レート制限保護)。
     * dedup_max_retry(リトライ最大回数、既定3 → 試行最大 4 回)× generate_with_retry 内部試行(GENERATE_MAX_RETRY=3 → 試行最大 4 回)で
     * 最悪 16 回まで膨らむ可能性がある。8 回程度を上限としておけば、dedup を 2 試行する間
     * 内部リトライが最大まで走っても収まり、レートリミット(429)を踏みにくい。
     */
    const AI_CALL_HARD_CAP = 8;

    /**
     * 今日の全タスクを実行（cron / 自動実行用）
     *
     * @return array 各タスクの結果 array( 'task_id' => int|WP_Error, ... )
     */
    public static function run() {
        $tasks = AIAP_Prompts::get_today_tasks();

        if ( empty( $tasks ) ) {
            AIAP_Logger::add( 'info', 'log_no_tasks_today', array( AIAP_Prompts::get_today_label()) );
            return array();
        }

        $results = array();
        foreach ( $tasks as $task ) {
            $results[ $task['id'] ] = self::run_single( $task, false );
        }

        return $results;
    }

    /**
     * AI生成結果から投稿を作成する共通処理
     *
     * 自動実行(run_single)と手動実行(ajax_run_now_step) の両方から呼ばれる。
     * 投稿ステータス検証 / カテゴリ / 投稿者 / 標準postmeta(_aiap_generated, _aiap_provider) の付与方針を
     * 1か所に集約することで、両経路で常に同じ挙動を保証する。
     *
     * @param array      $result      AIAP_AI_Client::generate() の戻り値 (title, content, slug 等)
     * @param array      $settings    AIAP_AI_Client::get_settings() の戻り値
     * @param string     $source      ログ用ソースキー ('src_manual' | 'src_auto')
     * @param string     $task_label  ログ表示用のタスクラベル (省略可)
     * @param array      $extra_meta  経路固有の追加postmeta (例: array( '_aiap_manual' => 1 ))
     * @param array|null $post_date   array( 'date_str' => 'Y-m-d', 'time_str' => 'H:i' ) を渡すとバックデート
     * @return int|WP_Error 投稿ID or エラー
     */
    public static function create_post_from_result( $result, $settings, $source, $task_label = '', $extra_meta = array(), $post_date = null ) {
        // タイトルまたは本文が空ならエラーで止める(空投稿の事故防止)
        $title_check   = trim( wp_strip_all_tags( (string) ( $result['title'] ?? '' ) ) );
        $content_check = trim( wp_strip_all_tags( (string) ( $result['content'] ?? '' ) ) );
        if ( $title_check === '' || $content_check === '' ) {
            $msg = __( 'AI response title or content is empty; aborted post creation', 'ai-auto-poster' );
            AIAP_Logger::add( 'error', 'log_empty_content_with_task', array( $source, $msg, $task_label ) );
            return new WP_Error( 'empty_content', $msg );
        }

        // slug 未指定時のフォールバック(手動実行で result.slug が無いケースに備える)
        $slug = isset( $result['slug'] ) ? $result['slug'] : 'post-' . wp_date( 'Ymd-His' );

        // Bug fix (audit r9 self-audit): post_title は MySQL の VARCHAR(255) なので、
        // それを超える長さの文字列を渡すと MySQL レベルで切り詰められる。マルチバイト
        // (UTF-8 で 3 バイト) の途中で切れると壊れた文字 (\xE3\x81 だけ等) が混入し、
        // wp_kses 経由でも修復できない。さらに DB のフルバイトカウントは utf8mb4 で
        // 765 バイト枠 (≒ 191 文字) なので、絵文字込みで 200 文字を超える AI 出力に
        // 対しても安全に収まるよう、保守的に 180 文字で打ち切る。
        $title_raw   = wp_strip_all_tags( $result['title'] );
        $title_capped = mb_substr( $title_raw, 0, 180, 'UTF-8' );

        $post_data = array(
            'post_title'    => $title_capped,
            'post_content'  => wp_kses_post( $result['content'] ),
            'post_name'     => sanitize_title( $slug ),
            'post_status'   => in_array( $settings['post_status'], array( 'draft', 'publish', 'pending' ), true ) ? $settings['post_status'] : 'draft',
            'post_author'   => (int) $settings['post_author'],
            'post_category' => $settings['post_category'] ? array( (int) $settings['post_category'] ) : array(),
        );

        // バックデート指示があれば post_date / post_date_gmt を上書き
        if ( is_array( $post_date ) && ! empty( $post_date['date_str'] ) && ! empty( $post_date['time_str'] ) ) {
            $backdate_local         = $post_date['date_str'] . ' ' . $post_date['time_str'] . ':00';
            $post_data['post_date'] = $backdate_local;
            try {
                $tz                         = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( date_default_timezone_get() );
                $dt                         = new DateTimeImmutable( $backdate_local, $tz );
                $dt_utc                     = $dt->setTimezone( new DateTimeZone( 'UTC' ) );
                $post_data['post_date_gmt'] = $dt_utc->format( 'Y-m-d H:i:s' );
            } catch ( Exception $e ) {
                // 失敗時は WP に任せる(post_date_gmt 未指定 → WPが自動計算)
            }
        }

        $post_id = wp_insert_post( $post_data, true );

        if ( is_wp_error( $post_id ) ) {
            AIAP_Logger::add( 'error', 'log_post_creation_failed', array( $source, $post_id->get_error_message() ) );
            return $post_id;
        }

        // Free プランの月次投稿カウンタを増加(Pro でも累積記録するが上限はかからない)
        AIAP_License::increment_post_count();

        // 標準postmeta(両経路共通)
        update_post_meta( $post_id, '_aiap_generated', 1 );
        update_post_meta( $post_id, '_aiap_provider', $settings['provider'] );

        // 経路固有postmeta(_aiap_manual, _aiap_task_id 等)
        if ( is_array( $extra_meta ) ) {
            foreach ( $extra_meta as $meta_key => $meta_value ) {
                if ( $meta_value === null || $meta_value === '' ) {
                    continue;
                }
                update_post_meta( $post_id, $meta_key, $meta_value );
            }
        }

        return (int) $post_id;
    }

    /**
     * 指定されたタスクを1件実行
     *
     * @param array|string $task_or_id タスク配列 or タスクID
     * @param bool         $is_manual  手動実行かどうか
     * @return int|WP_Error 投稿ID or エラー
     */
    public static function run_single( $task_or_id, $is_manual = true ) {
        // キー文字列を保持(ログ表示時に Logger 側で展開される)
        $source = $is_manual ? 'src_manual' : 'src_auto';

        // IDが渡された場合はタスクを取得
        if ( is_string( $task_or_id ) ) {
            $task = AIAP_Prompts::get_by_id( $task_or_id );
            if ( ! $task ) {
                $err = new WP_Error( 'task_not_found', __( 'Task not found', 'ai-auto-poster' ) );
                AIAP_Logger::add( 'error', 'log_source_error', array( $source, $err->get_error_message() ) );
                return $err;
            }
        } else {
            $task = $task_or_id;
        }

        if ( empty( $task['prompt'] ) ) {
            $err = new WP_Error( 'empty_prompt', __( 'Task prompt is empty', 'ai-auto-poster' ) );
            AIAP_Logger::add( 'error', 'log_source_error', array( $source, $err->get_error_message() ) );
            return $err;
        }

        $task_label = AIAP_Prompts::format_log_label( $task );

        if ( empty( $task['content_language'] ) ) {
            AIAP_Logger::add( 'error', 'log_task_missing_language', array( $source, $task_label ) );
            return new WP_Error( 'missing_language', __( 'Task has no content language set; skipped.', 'ai-auto-poster' ) );
        }

        $settings      = AIAP_AI_Client::get_settings();
        // Bug fix (audit r4): dedup is a Pro feature. handle_save_settings only writes
        // dedup_enabled when is_pro(), but a user who was Pro and downgraded retains a
        // saved dedup_enabled=1, which would silently keep using AI credits on Free.
        // Mirror what effective_recover_max() does for cron_recover_max — gate at runtime.
        $dedup_enabled = ! empty( $settings['dedup_enabled'] )
            && ( ! class_exists( 'AIAP_License' ) || AIAP_License::is_pro() );
        $max_retry     = max( 1, (int) ( $settings['dedup_max_retry'] ?? 3 ) );
        $current_prompt = $task['prompt'];

        // タスク固有の言語・文字数(設定があればグローバル値を上書き)
        $task_overrides = array();
        if ( ! empty( $task['content_language'] ) ) {
            $task_overrides['content_language'] = $task['content_language'];
        }
        if ( ! empty( $task['content_length'] ) ) {
            $task_overrides['content_length'] = (int) $task['content_length'];
        }

        // featured_image_mode (inherit/on/off) からこのタスクで実際にアイキャッチを付けるかを決定。
        // 値は generate_and_attach の override に使うほか、AI プロンプト生成側にも伝える(image_enabled_effective)。
        $image_mode              = AIAP_Prompts::get_image_mode( $task );
        $image_settings          = AIAP_Image_Client::get_settings();
        $effective_image_enabled = AIAP_Image_Client::resolve_image_enabled( $image_mode, $image_settings['image_enabled'] ?? 0 );
        $task_overrides['image_enabled_effective'] = $effective_image_enabled;

        // ===== RSS フェーズ =====
        // rss_enabled かつ Pro なら、選択フィードから未使用記事を取得して task_overrides に追加する。
        // 候補 0 件の時は rss_fallback に従って skip/generate を分岐。
        // Pro ゲートを実行時にも適用する（Pro→Free ダウングレード対策）。
        $rss_settings    = AIAP_Prompts::get_rss_settings( $task );
        $rss_used_articles = array();
        if ( $rss_settings['rss_enabled'] === 1 ) {
            $is_pro = class_exists( 'AIAP_License' ) && AIAP_License::is_pro();
            if ( ! $is_pro ) {
                AIAP_Logger::add( 'info', 'log_rss_skipped_free_plan', array( $source, $task_label ) );
            } else {
                $candidates = AIAP_RSS_Feeds::pick_unused( $rss_settings['rss_feed_ids'], $rss_settings['rss_count'] );
                if ( empty( $candidates ) ) {
                    if ( $rss_settings['rss_fallback'] === 'skip' ) {
                        AIAP_Logger::add( 'info', 'log_rss_skipped_no_articles', array( $source, $task_label ) );
                        return new WP_Error( 'rss_no_articles', __( 'No fresh RSS articles available; post skipped per task setting.', 'ai-auto-poster' ) );
                    }
                    AIAP_Logger::add( 'info', 'log_rss_fallback_to_normal', array( $source, $task_label ) );
                    // フォールバック時は RSS なしで通常生成（task_overrides に何も追加しない）
                } else {
                    $task_overrides['rss_articles'] = $candidates;
                    $task_overrides['rss_mode']     = $rss_settings['rss_mode'];
                    $rss_used_articles              = $candidates;
                }
            }
        }
        // ===== /RSS フェーズ =====

        // AI生成(dedup有効時はリトライループ)
        // max_retry(=dedup_max_retry) は「リトライ最大回数」。試行は最大 (max_retry + 1) 回(初回 + リトライ N 回)。
        //
        // API 呼び出し回数の安全上限:
        //   generate_with_retry は内部で最大 GENERATE_MAX_RETRY+1(=4)回 AI を呼ぶ。
        //   dedup ループは最大 max_retry+1(=4)回まわるので、最悪ケースで 16 回 AI を呼ぶ可能性がある。
        //   レート制限に当たりやすいため、累積 API 呼び出し回数を AI_CALL_HARD_CAP で制限する。
        $result        = null;
        $attempt       = 0;
        $recent        = $dedup_enabled ? AIAP_Dedup::get_recent_posts() : array();
        $dedup_result  = null;
        $ai_call_count = 0;
        $hard_cap      = self::AI_CALL_HARD_CAP;

        // 試行回数が max_retry を超えるまで(つまり最大 max_retry+1 回試行する)
        while ( $attempt <= $max_retry ) {
            $attempt++;

            // 累積 AI 呼び出し回数が上限を超えそうなら dedup ループを中断
            // (最低 1 回は呼ぶ前提なので $attempt===1 ではこの分岐に入らない)
            if ( $ai_call_count >= $hard_cap ) {
                AIAP_Logger::add( 'error', 'log_server_error_failed', array(
                    $source, $ai_call_count, '', $task_label,
                ) );
                break;
            }

            $result = AIAP_AI_Client::generate_with_retry( $current_prompt, function( $ai_attempt, $err ) use ( $source, $task_label, &$ai_call_count ) {
                $ai_call_count++;
                // Bug fix (audit r9 self-audit, #36/#37): レベルを 'warning' に統一し、
                // 分母を MAX_RETRY+1 (=4) にする。旧実装は 'info' + 分母 MAX_RETRY (=3) で:
                //   - 経路ごとにレベルがバラバラだった (UI=error, cron=info, image=info)
                //   - 「3/3」表示で「リトライ終了」と読めるが実際は 4 回目試行が残る、という UI 混乱
                AIAP_Logger::add( 'warning', 'log_server_error_retry', array(
                    $source, $ai_attempt, AIAP_AI_Client::GENERATE_MAX_RETRY + 1,
                    AIAP_AI_Errors::format_error_for_log( $err ), $task_label,
                ));
            }, $task_overrides, $is_manual );
            // 成功時は generate_with_retry のコールバックは呼ばれないため、
            // ここで成功分(初回試行)を 1 カウントする。コールバックでカウント済みのリトライ分と合計する。
            $ai_call_count++;

            if ( is_wp_error( $result ) ) {
                AIAP_Logger::add( 'error', 'log_server_error_failed', array(
                    $source, $ai_call_count,
                    AIAP_AI_Errors::format_error_for_log( $result ), $task_label,
                ));
                return $result;
            }

            if ( ! $dedup_enabled ) {
                break;
            }

            $dedup_result = AIAP_Dedup::check( $result['title'], $result['content'] );

            if ( empty( $dedup_result['is_similar'] ) ) {
                if ( $attempt > 1 ) {
                    AIAP_Logger::add( 'info', 'log_dedup_passed', array(
                        $source, $attempt, $dedup_result['method'],
                    ));
                }
                break;
            }

            $reason_text = is_string( $dedup_result['reason'] ?? '' ) ? (string) $dedup_result['reason'] : '';

            // Bug fix (audit r9): UI ラベル「Max retries」とクラスコメント L181 のとおり、
            // dedup_max_retry は「リトライ最大回数」を意味する (=初回1回 + max_retry 回 = 試行 max_retry+1 回)。
            // 旧条件 `$attempt >= $max_retry` だと max_retry=3 のとき iteration 3 で break してしまい、
            // 試行 3 回しか走っていなかった (初回 + リトライ 2 回)。`$attempt > $max_retry` に変更して
            // 初回 + max_retry 回 = 4 回まで試行できるようにする。
            if ( $attempt > $max_retry ) {
                AIAP_Logger::add( 'info', 'log_dedup_max_reached', array( $source, $max_retry ) );
                break;
            }

            AIAP_Logger::add( 'info', 'log_dedup_retry', array(
                $source, $attempt, $max_retry,
                $dedup_result['method'], mb_substr( $reason_text, 0, 80 ),
            ));

            $current_prompt = AIAP_Dedup::build_retry_prompt(
                $task['prompt'],
                $reason_text,
                $recent,
                $result
            );
        }
        // 投稿作成(共通処理)
        // バックデート: cron取りこぼし拾い時に予定時刻で投稿されたように見せる
        // (自動経由 & override が新しい場合のみ適用。取りこぼし復元時は必ず予定日時を使用)
        $post_date = null;
        $schedule_run_key = '';
        if ( ! $is_manual && class_exists( 'AIAP_Scheduler' ) ) {
            $override = get_option( AIAP_Scheduler::OPTION_OVERRIDE_POSTDATE, array() );
            if ( is_array( $override )
                && isset( $override['task_id'] )
                && $override['task_id'] === $task['id']
                // 古い情報(5分以上前にセット)は無視(誤適用防止)
                && isset( $override['set_at'] )
                && ( time() - (int) $override['set_at'] ) < 300
            ) {
                if ( ! empty( $override['run_key'] ) ) {
                    $schedule_run_key = (string) $override['run_key'];
                }
                if ( ! empty( $override['date_str'] ) && ! empty( $override['time_str'] ) ) {
                    // 取りこぼし復元時は必ず本来の予定日時を post_date に使う。
                    // 記事内容が朝/昼/夜の予定に紐づくため、現在時刻で公開すると時刻と内容が不一致になる。
                    $post_date = array(
                        'date_str' => $override['date_str'],
                        'time_str' => $override['time_str'],
                    );
                }
            }
        }

        // RSS 使用記事の決定 (pick モードは AI の selected_rss_index、digest モードは全件)
        $rss_consumed = array();
        if ( ! empty( $rss_used_articles ) ) {
            if ( $rss_settings['rss_mode'] === 'pick' ) {
                $index = isset( $result['selected_rss_index'] ) ? (int) $result['selected_rss_index'] : 0;
                if ( $index < 1 || $index > count( $rss_used_articles ) ) {
                    AIAP_Logger::add( 'warning', 'log_rss_pick_index_invalid', array(
                        $source, $index, count( $rss_used_articles ),
                    ) );
                    $index = 1;
                }
                $rss_consumed = array( $rss_used_articles[ $index - 1 ] );
            } else {
                $rss_consumed = $rss_used_articles;
            }
        }

        // 経路固有 postmeta (= 自動経路は task_id + バックデート情報)
        $extra_meta = array(
            '_aiap_task_id'          => $task['id'],
            '_aiap_schedule_run_key' => $schedule_run_key,
            '_aiap_scheduled_date'   => is_array( $post_date ) && ! empty( $post_date['date_str'] ) ? $post_date['date_str'] : '',
            '_aiap_scheduled_time'   => is_array( $post_date ) && ! empty( $post_date['time_str'] ) ? $post_date['time_str'] : '',
        );

        // Phase: 投稿作成 (= create_post_from_result + RSS extra_meta + increment_used + RSS mark_used)
        $post_id = self::phase_create_post(
            $result, $task, $settings, $source, $task_label,
            $rss_consumed, $rss_settings, $extra_meta, $post_date
        );
        if ( is_wp_error( $post_id ) ) {
            return $post_id;
        }

        // Phase: アイキャッチ画像の生成・添付 (失敗時はログ記録、WP_Error 戻り値は捨てる:
        //  投稿は既に作成済みなので画像失敗で投稿全体を失敗扱いにしない)
        self::phase_attach_image( $post_id, $result, $task, $effective_image_enabled, $source );

        // Phase: SEO 適用 + 完了ログ (source 別キー分岐: 自動経路は log_post_created)
        self::phase_finalize(
            $post_id, $result, $settings, $source, $task_label,
            $rss_consumed, $rss_settings, $post_date
        );

        return $post_id;
    }

    /**
     * Phase: 投稿作成 (= create_post_from_result 呼出 + RSS extra_meta 構築 +
     *         AIAP_Prompts::increment_used + AIAP_RSS_State::mark_used)
     *
     * 自動経路 (run_single 内) と手動経路 (admin-runner.php の post step) の
     * 両方から呼ばれる共通処理。投稿作成後の使用回数加算・RSS 履歴記録までを一括で行う。
     *
     * @param array       $result        AI 生成結果 (title/content/slug/image_prompt 等)
     * @param array       $task          タスク配列 (id を含む)
     * @param array       $settings      AI 設定
     * @param string      $source        'src_manual' / 'src_auto' / 'src_cron'
     * @param string      $task_label    ログ用ラベル
     * @param array       $rss_consumed  RSS 消費記事 (なければ空配列、extra_meta + mark_used に使う)
     * @param array       $rss_settings  RSS 設定 (rss_mode 等、extra_meta 構築に使う)
     * @param array       $extra_meta    経路固有の追加 postmeta (例: _aiap_manual=1, _aiap_task_id 等)
     * @param array|null  $post_date     バックデート情報 (= 自動経路 cron 取りこぼし時のみ)
     * @return int|WP_Error 投稿 ID or エラー
     */
    public static function phase_create_post( $result, $task, $settings, $source, $task_label, $rss_consumed, $rss_settings, $extra_meta = array(), $post_date = null ) {
        // RSS extra_meta 構築 (= 消費記事があれば付与)
        $rss_extra_meta = array();
        if ( ! empty( $rss_consumed ) ) {
            $urls     = array();
            $guids    = array();
            $feed_ids = array();
            foreach ( $rss_consumed as $a ) {
                $urls[]     = isset( $a['link'] )    ? (string) $a['link']    : '';
                $guids[]    = isset( $a['guid'] )    ? (string) $a['guid']    : '';
                $feed_ids[] = isset( $a['feed_id'] ) ? (string) $a['feed_id'] : '';
            }
            $rss_extra_meta = array(
                '_aiap_rss_source_urls'  => $urls,
                '_aiap_rss_source_guids' => $guids,
                '_aiap_rss_feed_ids'     => $feed_ids,
                '_aiap_rss_mode'         => $rss_settings['rss_mode'],
            );
        }

        $merged_meta = array_merge( $extra_meta, $rss_extra_meta );

        $post_id = self::create_post_from_result(
            $result, $settings, $source, $task_label, $merged_meta, $post_date
        );

        if ( is_wp_error( $post_id ) ) {
            return $post_id;
        }

        // 使用回数をインクリメント (task['id'] が空文字ならスキップ:
        //  クイック投稿モードでは task が DB に無いため。
        //  AIAP_Prompts::increment_used は存在しない ID では no-op だが、
        //  無駄な save_all 呼出を避けるためここでチェックする)
        if ( ! empty( $task['id'] ) ) {
            AIAP_Prompts::increment_used( $task['id'] );
        }

        // RSS: 消費した記事を使用済み履歴に追加
        if ( ! empty( $rss_consumed ) ) {
            foreach ( $rss_consumed as $a ) {
                if ( ! empty( $a['feed_id'] ) && ! empty( $a['guid'] ) ) {
                    AIAP_RSS_State::mark_used( $a['feed_id'], $a['guid'], (int) $post_id );
                }
            }
        }

        return $post_id;
    }

    /**
     * Phase: アイキャッチ画像の生成・添付
     *
     * 自動経路 (run_single 内) と手動経路 (admin-runner.php の image step) の
     * 両方から呼ばれる共通処理。画像生成失敗時は log_thumbnail_failed を残して
     * WP_Error を返す (投稿は既に作成済みなので、画像失敗は投稿の失敗を意味しない)。
     *
     * @param int    $post_id                 投稿 ID (= phase_create_post の戻り値)
     * @param array  $result                  AI 生成結果 (title/slug/image_prompt/content)
     * @param array  $task                    タスク配列 (prompt を含む、画像生成プロンプトの補助情報)
     * @param int    $effective_image_enabled 画像有効/無効の最終解決値 (0/1)
     * @param string $source                  'src_manual' / 'src_auto' / 'src_cron'
     * @return true|WP_Error 成功 or 失敗 (= 失敗時はログ記録済み)
     */
    public static function phase_attach_image( $post_id, $result, $task, $effective_image_enabled, $source ) {
        $preset_ip        = isset( $result['image_prompt'] ) ? (string) $result['image_prompt'] : '';
        $user_prompt_full = isset( $task['prompt'] )         ? (string) $task['prompt']         : '';
        $article_body     = isset( $result['content'] )      ? (string) $result['content']      : '';

        $image_result = AIAP_Image_Client::generate_and_attach(
            $post_id, $result['title'], $result['slug'], $preset_ip,
            $user_prompt_full, $article_body, $effective_image_enabled, $source
        );

        if ( is_wp_error( $image_result ) ) {
            AIAP_Logger::add( 'error', 'log_thumbnail_failed', array(
                $source, $post_id, AIAP_AI_Errors::format_error_for_log( $image_result ),
            ) );
            return $image_result;
        }

        return true;
    }

    /**
     * Phase: SEO 適用 + 完了ログ
     *
     * 自動経路 (run_single 内) と手動経路 (admin-runner.php の seo step) の
     * 両方から呼ばれる共通処理。
     *
     * 完了ログのキーは source 別に分岐する (= UI 表示形式の互換性維持、L19 遵守):
     *   - src_manual → log_manual_post_created / _with_rss (短形式「Manual: Post created [ID:X] title」)
     *   - その他 (cron) → log_post_created / _with_rss (詳細形式「<source>: Post created [ID:X] title (task: Y, AI: Z)」)
     *
     * @param int        $post_id        投稿 ID
     * @param array      $result         AI 生成結果 (title 等)
     * @param array      $settings       AI 設定 (provider を含む、完了ログに使う)
     * @param string     $source         'src_manual' / 'src_auto' / 'src_cron'
     * @param string     $task_label     ログ用タスクラベル
     * @param array      $rss_consumed   RSS 消費記事 (なければ空配列、ログサマリに使う)
     * @param array      $rss_settings   RSS 設定 (rss_mode、ログサマリ構築に使う)
     * @param array|null $post_date      バックデート (= 完了ログの task_label に "対象: 日時" を追加)
     * @return void
     */
    public static function phase_finalize( $post_id, $result, $settings, $source, $task_label, $rss_consumed, $rss_settings, $post_date = null ) {
        // SEO 処理を適用 (seo_enabled の判定は AIAP_SEO::apply() 内部に委譲)
        AIAP_SEO::apply( $post_id, $result, $settings );

        // バックデート情報を log_task_label に追加 (= 自動経路 cron 取りこぼし時のみ)
        $log_task_label = $task_label;
        if ( is_array( $post_date ) && ! empty( $post_date['date_str'] ) && ! empty( $post_date['time_str'] ) ) {
            $log_task_label .= ' / 対象: ' . $post_date['date_str'] . ' ' . $post_date['time_str'];
        }

        // RSS ログサマリ
        $rss_log_summary = ! empty( $rss_consumed )
            ? AIAP_RSS_Composer::build_log_summary( $rss_consumed, $rss_settings['rss_mode'] )
            : '';

        $title = wp_strip_all_tags( (string) $result['title'] );

        // 完了ログ: source 別にキーを分岐 (= UI 表示形式の互換性維持)
        if ( $source === 'src_manual' ) {
            // 手動経路は短形式キー (admin-runner.php case 'post' で出していた旧形式と同じ)
            if ( $rss_log_summary !== '' ) {
                AIAP_Logger::add( 'success', 'log_manual_post_created_with_rss', array(
                    $post_id, $title, $rss_log_summary,
                ) );
            } else {
                AIAP_Logger::add( 'success', 'log_manual_post_created', array(
                    $post_id, $title,
                ) );
            }
        } else {
            // 自動経路 (cron) は詳細形式キー
            if ( $rss_log_summary !== '' ) {
                AIAP_Logger::add( 'success', 'log_post_created_with_rss', array(
                    $source, $post_id, $title, $log_task_label, $settings['provider'], $rss_log_summary,
                ) );
            } else {
                AIAP_Logger::add( 'success', 'log_post_created', array(
                    $source, $post_id, $title, $log_task_label, $settings['provider'],
                ) );
            }
        }
    }

    /**
     * 今日のタスクを全実行（手動実行の空欄時に使用）
     *
     * Free プラン上限到達時は、残り枠ぶんだけ実行して残りはスキップする。
     * スキップ件数はログに記録される。
     *
     * @param string $source ログ用ソース名
     * @return int 実行件数
     */
    public static function run_today( $source = 'manual' ) {
        $tasks = AIAP_Prompts::get_today_tasks();
        $count = 0;
        $skipped = 0;

        foreach ( $tasks as $task ) {
            // 1件ごとに残り枠を確認(Pro なら常に true)
            if ( ! AIAP_License::can_post_more() ) {
                $skipped++;
                continue;
            }
            $result = self::run_single( $task, true );
            if ( ! is_wp_error( $result ) ) {
                $count++;
            }
        }

        if ( $skipped > 0 ) {
            AIAP_Logger::add( 'info', 'log_free_limit_reached_skip_cron' );
        }

        return $count;
    }
}
