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



    /**

     * OVERRIDE_POSTDATE の鮮度上限(秒)。

     *

     * scheduler.php が OPTION_OVERRIDE_POSTDATE に set_at を書き込んでから、AI 生成・dedup・

     * 画像生成・SEO 適用までの一連の処理が完了するまでの「想定最大経過時間」。

     *

     * 旧値 300 秒は AI のリトライ(最大 16 回)・画像生成・SEO・通知のワーストケースで

     * 簡単に超過し、超過すると post_date バックデートと run_key meta の両方が欠落して

     * 「未実行扱いの投稿」が作成され、後続 cron で重複生成され得る不整合を生んでいた。

     * 1800 秒(30分)に拡大して通常運用の上振れを吸収する。

     */

    const OVERRIDE_POSTDATE_MAX_AGE_SEC = 1800;



    /**

     * 今日の全タスクを実行（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;

    }



    /**

     * プロバイダキー → 表示用ラベル変換 (claude → 'Claude' 等、固有名詞は両言語共通)

     *

     * 投稿一覧 UI とメール通知の双方で「表示が完全一致」させるための単一情報源。

     *

     * @param string $provider プロバイダキー (claude / openai / gemini / mistral / groq / deepseek / openrouter / '' )

     * @return string 表示用ラベル (未設定なら '—')

     */

    public static function format_provider_label( $provider ) {

        $provider = (string) $provider;

        if ( $provider === '' ) {

            return '—';

        }

        $labels = array(

            'claude'     => 'Claude',

            'openai'     => 'OpenAI',

            'gemini'     => 'Gemini',

            'mistral'    => 'Mistral',

            'groq'       => 'Groq',

            'deepseek'   => 'DeepSeek',

            'openrouter' => 'OpenRouter',

        );

        return isset( $labels[ $provider ] ) ? $labels[ $provider ] : $provider;

    }



    /**

     * 投稿ステータスキー → ローカライズ表示名

     *

     * @param string $status 'publish' / 'draft' / 'future' / 'pending' / 'private'

     * @return string 表示名 (未知値はキーをそのまま返す)

     */

    public static function format_status_label( $status ) {

        $labels = array(

            'publish' => __( 'Published',      'ai-auto-poster' ),

            'draft'   => __( 'Draft',          'ai-auto-poster' ),

            'future'  => __( 'Scheduled',      'ai-auto-poster' ),

            'pending' => __( 'Pending Review', 'ai-auto-poster' ),

            'private' => __( 'Private',        'ai-auto-poster' ),

        );

        return isset( $labels[ $status ] ) ? $labels[ $status ] : (string) $status;

    }



    /**

     * 投稿アイテムからタスク表示名を組み立て

     *

     * 手動 → 'Manual'、自動でタスク名あり → タスク名、なし → 'Task #ID' or '—'

     *

     * @param array $item build_item_for_post の戻り値相当

     * @return string

     */

    public static function format_task_name( $item ) {

        if ( ! empty( $item['is_manual'] ) ) {

            return __( 'Manual', 'ai-auto-poster' );

        }

        $task_id = isset( $item['task_id'] ) ? (string) $item['task_id'] : '';

        if ( $task_id === '' ) {

            return '—';

        }

        if ( class_exists( 'AIAP_Prompts' ) ) {

            $task = AIAP_Prompts::get_by_id( $task_id );

            if ( is_array( $task ) && isset( $task['name'] ) && (string) $task['name'] !== '' ) {

                return (string) $task['name'];

            }

        }

        /* translators: %s: task ID */

        return sprintf( __( 'Task #%s', 'ai-auto-poster' ), $task_id );

    }



    /**

     * 投稿 ID から「投稿アイテム配列」を組み立てる共通ヘルパ

     *

     * 投稿一覧テーブル (AIAP_Generated_Posts_Table) とメール通知 (AIAP_Notifier) の

     * 双方が「投稿に関する単一情報源」として本メソッドを呼ぶ。

     *

     * AIAP_Poster は cron / admin / AJAX のいずれでも必ずロードされるため、

     * is_admin() の状態に左右されない (= 旧実装が extends WP_List_Table の管理画面

     * 専用クラスにあったために cron でサイレント失敗していた問題を解消する位置)。

     *

     * @param int|WP_Post $post_or_id 投稿 ID または WP_Post

     * @return array|null              投稿アイテム配列 / 投稿不在時は null

     */

    public static function build_item_for_post( $post_or_id ) {

        $post = $post_or_id instanceof WP_Post ? $post_or_id : get_post( (int) $post_or_id );

        if ( ! $post ) {

            return null;

        }

        return array(

            'ID'        => $post->ID,

            // タイムスタンプ保持 (WPタイムゾーン考慮済み)。$post->post_date 文字列は

            // PHP サーバーの TZ で strtotime 解釈されてズレるため使わない。

            'date'      => (int) get_post_timestamp( $post ),

            'status'    => $post->post_status,

            'title'     => $post->post_title,

            'thumb_id'  => (int) get_post_thumbnail_id( $post->ID ),

            'provider'  => (string) get_post_meta( $post->ID, '_aiap_provider', true ),

            'task_id'   => (string) get_post_meta( $post->ID, '_aiap_task_id', true ),

            'is_manual' => (bool) get_post_meta( $post->ID, '_aiap_manual', true ),

            // edit_url は context 非依存に admin_url で組み立てる (cron でも null にならない)

            'edit_url'  => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ),

            'view_url'  => $post->post_status === 'publish' ? (string) get_permalink( $post->ID ) : '',

        );

    }



    /**

     * RSS 使用記事の決定ロジックを共通化したヘルパ

     *

     * pick モードは AI 応答の selected_rss_index (1-based) で 1 件確定、

     * digest モードは全件採用。範囲外の index は安全に 1 に丸めて警告ログを出す。

     *

     * 旧実装は AIAP_Poster::run_single (自動経路) と admin-runner.php (手動経路) で

     * 同一ロジックが再掲されていた (CLAUDE.md「同一処理が複数箇所」NG)。

     *

     * @param array  $rss_used_articles pick_unused の戻り (空配列なら何も消費しない)

     * @param array  $rss_settings      'rss_mode' を持つ設定配列

     * @param array  $result            AI 応答 (selected_rss_index を持つ可能性)

     * @param string $source            ログ用 ('src_auto' / 'src_manual')

     * @return array 消費する記事配列 (extra_meta + RSS_State::mark_used に渡す形)

     */

    public static function resolve_rss_consumed( $rss_used_articles, $rss_settings, $result, $source ) {

        if ( empty( $rss_used_articles ) ) {

            return array();

        }

        $rss_mode = isset( $rss_settings['rss_mode'] ) ? (string) $rss_settings['rss_mode'] : 'pick';

        if ( $rss_mode === 'pick' ) {

            $index = isset( $result['selected_rss_index'] ) ? (int) $result['selected_rss_index'] : 0;

            if ( $index < 1 || $index > count( $rss_used_articles ) ) {

                if ( class_exists( 'AIAP_Logger' ) ) {

                    AIAP_Logger::add( 'warning', 'log_rss_pick_index_invalid', array(

                        $source, $index, count( $rss_used_articles ),

                    ) );

                }

                $index = 1;

            }

            return array( $rss_used_articles[ $index - 1 ] );

        }

        // digest モード等は全件採用

        return $rss_used_articles;

    }



    /**

     * 自動経路 (cron) のバックデート情報を OVERRIDE_POSTDATE から読み込む

     *

     * 戻り値:

     *   array(

     *       'post_date'        => array('date_str','time_str') | null,

     *       'schedule_run_key' => string (空可),

     *   )

     *

     * 古い情報 (set_at から 300 秒以上経過) は無視する。手動経路は呼ばない。

     *

     * @param array $task タスク配列 (id を持つ)

     * @return array

     */

    /**

     * pick モードで予約したが採用しなかった候補の予約を解放する。

     *

     * pick_unused() は候補を全件 reserve() するが、pick モードでは AI が選んだ

     * 1 件しか消費しない (resolve_rss_consumed)。採用しなかった候補の予約を

     * 解放しないと RESERVATION_TTL_SECONDS (600秒) まで他経路 (並行 cron /

     * Quick Post) の pick をブロックし続け、rss_fallback='skip' 設定では投稿が

     * 不要にスキップされる。

     *

     * 消費する記事の予約は phase_create_post の mark_used が同一トランザクション

     * 内で解除するため、ここでは対象外とする。digest モードは全件消費するため

     * 解放対象が無く、自然に no-op になる。

     *

     * picked / consumed は同一の pick_unused 戻り由来で guid は同形式のため、

     * 集合比較は raw guid で行う (release_reservation 側が内部で normalize する)。

     *

     * @param array $picked   pick_unused の戻り (予約済みの全候補)

     * @param array $consumed resolve_rss_consumed の戻り (実際に消費する記事)

     * @return void

     */

    public static function release_unconsumed_reservations( $picked, $consumed ) {

        if ( empty( $picked ) || ! class_exists( 'AIAP_RSS_State' ) ) {

            return;

        }

        $consumed_keys = array();

        foreach ( (array) $consumed as $a ) {

            $fid  = isset( $a['feed_id'] ) ? (string) $a['feed_id'] : '';

            $guid = isset( $a['guid'] )    ? (string) $a['guid']    : '';

            if ( $fid !== '' && $guid !== '' ) {

                $consumed_keys[ $fid . "\n" . $guid ] = true;

            }

        }

        foreach ( (array) $picked as $a ) {

            $fid  = isset( $a['feed_id'] ) ? (string) $a['feed_id'] : '';

            $guid = isset( $a['guid'] )    ? (string) $a['guid']    : '';

            if ( $fid === '' || $guid === '' ) {

                continue;

            }

            if ( isset( $consumed_keys[ $fid . "\n" . $guid ] ) ) {

                continue; // 消費する記事は mark_used が解除する

            }

            AIAP_RSS_State::release_reservation( $fid, $guid );

        }

    }



    public static function resolve_auto_post_date( $task ) {

        $out = array( 'post_date' => null, 'schedule_run_key' => '' );

        if ( ! class_exists( 'AIAP_Scheduler' ) ) {

            return $out;

        }

        $override = get_option( AIAP_Scheduler::OPTION_OVERRIDE_POSTDATE, array() );

        if ( ! is_array( $override ) ) {

            return $out;

        }

        // task_id 一致 + set_at が 300 秒以内

        if ( ! isset( $override['task_id'] ) || $override['task_id'] !== $task['id'] ) {

            return $out;

        }

        if ( ! isset( $override['set_at'] ) || ( time() - (int) $override['set_at'] ) >= self::OVERRIDE_POSTDATE_MAX_AGE_SEC ) {

            return $out;

        }

        if ( ! empty( $override['run_key'] ) ) {

            $out['schedule_run_key'] = (string) $override['run_key'];

        }

        if ( ! empty( $override['date_str'] ) && ! empty( $override['time_str'] ) ) {

            // 取りこぼし復元時は本来の予定日時を post_date に使う

            $out['post_date'] = array(

                'date_str' => (string) $override['date_str'],

                'time_str' => (string) $override['time_str'],

            );

        }

        return $out;

    }



    /**

     * 経路別の extra_meta (= phase_create_post に渡す追加 postmeta) を組み立てる

     *

     * 自動経路: _aiap_task_id, _aiap_schedule_run_key, _aiap_scheduled_date, _aiap_scheduled_time

     * 手動経路: _aiap_manual=1, _aiap_task_id (空可), _aiap_idempotency_key (空可)

     *

     * 旧実装は両経路でそれぞれ手書きで組み立てており、メタキーの追加忘れが発生しやすかった。

     *

     * @param string     $source           'src_auto' / 'src_manual'

     * @param array      $task             タスク配列 (id を持つ)

     * @param array|null $post_date        バックデート ('date_str','time_str') または null

     * @param string     $schedule_run_key 自動経路の run_key (空可)

     * @param array      $manual_context   手動経路用: array('idempotency_key' => ...)

     * @return array postmeta key=>value

     */

    /**

     * 実行開始トリガログを共通発火する (自動 cron / 手動 Quick Post 両経路から呼ぶ)

     *

     * 旧実装は scheduler.php (自動) と admin-runner.php (手動) で「実行開始ログ」を

     * 独立に AIAP_Logger::add(...) しており、同一意味のイベント (= 投稿実行開始) が

     * 共通化されていなかった (CLAUDE.md「同じ処理が複数箇所にある場合は共通化」)。

     *

     * source 別に出力するログキー:

     *   - 'src_manual'           → log_manual_run_started

     *   - 'src_auto' (取りこぼし) → log_auto_recovered_late

     *   - 'src_auto' (通常)      → log_auto_run_started

     *

     * @param string $source  'src_auto' / 'src_manual'

     * @param array  $context 出力に必要なコンテキスト:

     *                         - 共通: 'task_label' (string)

     *                         - auto: 'date_str' (Y-m-d), 'time' (HH:MM), 'queue_remaining' (int)

     *                         - auto 取りこぼし時のみ: 'is_late' (bool true), 'minutes_late' (int)

     *                         - manual: 'task_label' のみ (= 登録済みタスクのラベル or '(quick post)')

     * @return void

     */

    public static function log_run_started( $source, $context = array() ) {

        $task_label = isset( $context['task_label'] ) ? (string) $context['task_label'] : '';



        if ( $source === 'src_manual' ) {

            if ( $task_label === '' ) {

                $task_label = __( '(quick post)', 'ai-auto-poster' );

            }

            AIAP_Logger::add( 'info', 'log_manual_run_started', array( $task_label ) );

            return;

        }



        // 自動経路 (src_auto / src_cron)

        $date_str        = isset( $context['date_str'] ) ? (string) $context['date_str'] : '';

        $time_str        = isset( $context['time'] ) ? (string) $context['time'] : '';

        $queue_remaining = isset( $context['queue_remaining'] ) ? (int) $context['queue_remaining'] : 0;



        if ( ! empty( $context['is_late'] ) ) {

            $minutes_late = isset( $context['minutes_late'] ) ? (int) $context['minutes_late'] : 0;

            AIAP_Logger::add( 'info', 'log_auto_recovered_late', array(

                $minutes_late, $date_str, $time_str, $task_label, $queue_remaining,

            ) );

            return;

        }

        AIAP_Logger::add( 'info', 'log_auto_run_started', array(

            $date_str, $time_str, $task_label, $queue_remaining,

        ) );

    }



    /**

     * 実行キャンセルログ (現状は手動経路のみが対象、cron は cancellable でない)

     *

     * @param string $source 'src_manual' のみ想定

     * @return void

     */

    public static function log_run_cancelled( $source ) {

        if ( $source === 'src_manual' ) {

            AIAP_Logger::add( 'info', 'log_manual_run_cancelled', array() );

        }

    }



    public static function build_extra_meta( $source, $task, $post_date = null, $schedule_run_key = '', $manual_context = array() ) {

        if ( $source === 'src_manual' ) {

            $extra = array( '_aiap_manual' => 1 );

            if ( ! empty( $task['id'] ) ) {

                $extra['_aiap_task_id'] = $task['id'];

            }

            if ( ! empty( $manual_context['idempotency_key'] ) ) {

                $extra['_aiap_idempotency_key'] = (string) $manual_context['idempotency_key'];

            }

            return $extra;

        }

        // 自動経路 (src_auto / src_cron 等)

        return array(

            '_aiap_task_id'          => isset( $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'] : '',

        );

    }



    /**

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

                // AI 応答のタイミング依存: パースは通ったが title/content が空になる現象は

                // モデル側の一時的な揺らぎで、リトライで解消する可能性が高い。

                array( 'retryable' => true, 'kind' => 'empty_content' )

            );

        }



        // 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 = AIAP_Compat::mb_substr( $title_raw, 0, 180, 'UTF-8' );



        // Bug fix: 旧実装は wp_insert_post 成功直後に increment_post_count() を呼び、その後で

        // _aiap_generated meta を update_post_meta していた。間で fatal/DB エラーが起きると

        // 「License カウンタ +1 されたが _aiap_generated メタが無い投稿」が残り、月次集計

        // (count_posts_this_month) と License カウンタが恒久的にズレる原因になる。

        // meta_input を使い、wp_insert_post の同一トランザクションで _aiap_generated と

        // _aiap_provider を書き込んで atomic 性を確保する。

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

            'meta_input'    => array(

                '_aiap_generated' => 1,

                '_aiap_provider'  => isset( $settings['provider'] ) ? (string) $settings['provider'] : '',

            ),

        );



        // バックデート指示があれば 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;

        }



        // 標準postmeta は meta_input で wp_insert_post と atomic に保存済み。

        // 経路固有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 );

            }

        }



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

        // _aiap_generated meta は meta_input で atomic に書き込み済みなので、ここで increment

        // するタイミングでは「meta 有り + カウンタ済み」または「meta 無し + カウンタ未」のいずれかが

        // 保証される(部分失敗での恒久ズレが発生しない)。

        AIAP_License::increment_post_count();



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

                    // 永続エラー: タスク ID が存在しない = 設定ミス or 削除済み。

                    array( 'retryable' => false, 'kind' => 'task_not_found' )

                );

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

                // 永続エラー: タスクの prompt 欄が空 = 設定ミス。

                array( 'retryable' => false, 'kind' => 'empty_prompt' )

            );

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

                // 永続エラー: タスクの言語未設定 = 設定ミス。

                array( 'retryable' => false, 'kind' => 'missing_language' )

            );

        }



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

                            // 永続扱い: ユーザーが意図的に skip 設定しているため、リトライで挙動を変えない。

                            array( 'retryable' => false, 'kind' => 'rss_no_articles' )

                        );

                    }

                    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;

        // Bug fix: ハードキャップで while を抜けた時に、最後の $result が「retry 判定された

        // 不採用」のままで投稿されないよう、ループ脱出時に「採用」フラグを明示する。

        // - dedup 無効時の break / outcome != 'retry' での break のみ accepted=true

        // - ハードキャップ break / ループ自然終了 (max_retry 超過) は accepted=false のまま

        $accepted = false;



        // 試行回数が 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_dedup_hard_cap_reached', 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 ) {

                $accepted = true;

                break;

            }



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

            $outcome      = AIAP_Dedup::classify_attempt_outcome( $dedup_result, $attempt, $max_retry, $source );



            // accept / give_up_accept はいずれもループを抜けて生成結果を採用する。

            // ログは classify_attempt_outcome 側で出力済み (DRY)。

            if ( $outcome !== 'retry' ) {

                $accepted = true;

                break;

            }



            // retry: 次イテレーション用にプロンプトを再構築

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

            $current_prompt = AIAP_Dedup::build_retry_prompt(

                $task['prompt'],

                $reason_text,

                $recent,

                $result

            );

        }



        // Bug fix: ハードキャップ break または max_retry 超過の自然終了で $accepted=false の場合、

        // 最後の $result は「retry 判定された不採用結果」または null のまま。

        // この場合は投稿せず、エラー終了する (旧実装は不採用結果で投稿していた)。

        if ( ! $accepted ) {

            return new WP_Error(

                'dedup_aborted',

                __( 'Post creation aborted: dedup retry limit or AI call hard-cap reached without accepted result.', 'ai-auto-poster' ),

                // 永続エラー: dedup リトライ上限到達。これ以上リトライしても解消しない。

                array( 'retryable' => false, 'kind' => 'dedup_aborted' )

            );

        }

        // 投稿作成(共通処理)

        // バックデート: cron 取りこぼし拾い時に予定時刻で投稿されたように見せる。

        // 自動経路のみが OVERRIDE_POSTDATE を読む (手動は経由しない)。

        $post_date        = null;

        $schedule_run_key = '';

        if ( ! $is_manual ) {

            $override_info    = self::resolve_auto_post_date( $task );

            $post_date        = $override_info['post_date'];

            $schedule_run_key = $override_info['schedule_run_key'];

        }



        // RSS 使用記事の決定 (pick/digest モード共通ヘルパ)

        $rss_consumed = self::resolve_rss_consumed( $rss_used_articles, $rss_settings, $result, $source );

        // pick モードで採用しなかった予約候補を解放 (TTL まで残ると他経路の pick をブロックする)

        self::release_unconsumed_reservations( $rss_used_articles, $rss_consumed );



        // 経路固有 postmeta (= 自動経路は task_id + バックデート情報、手動経路は _aiap_manual 等)

        $extra_meta = self::build_extra_meta( $source, $task, $post_date, $schedule_run_key );



        // 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'],

                ) );

            }

        }



        // 投稿完了通知フック: 投稿履歴 (log_*_post_created) を DB に書き込んだ直後に発火する。

        // AIAP_Notifier がこのフックでメール送信を行う (= 通知設定が有効な場合のみ)。

        // 他の拡張機能もこのフックで投稿完了イベントを受け取れる (= WordPress 標準のフック設計、L21)。

        do_action( 'aiap_post_created', (int) $post_id, $result, $settings, $source, $task_label );

    }



    /**

     * 今日のタスクを全実行（手動実行の空欄時に使用）

     *

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

    }

}

