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

/**
 * 記事生成のオーケストレーションクラス
 *
 * 設定の取得・system_text の組み立て・retry 制御・プロバイダ選択を担当する。
 * 実際の API 呼出本体 (Claude / OpenAI / Gemini / Mistral / Groq / DeepSeek /
 * OpenRouter) は AIAP_AI_Providers に分離されている。
 * (Mistral/Groq/DeepSeek/OpenRouter は OpenAI 互換 API)
 */
class AIAP_AI_Client {

    const OPTION_KEY = 'aiap_settings';

    public static function get_default_settings() {
        return array(
            // プロバイダ選択
            'provider'            => 'claude',
            // Claude
            'claude_api_key'      => '',
            'claude_model'        => AIAP_Model_Registry::default_text_model( 'claude' ),
            // OpenAI
            'openai_api_key'      => '',
            'openai_model'        => AIAP_Model_Registry::default_text_model( 'openai' ),
            // Gemini
            'gemini_api_key'      => '',
            'gemini_model'        => AIAP_Model_Registry::default_text_model( 'gemini' ),
            // Mistral
            'mistral_api_key'     => '',
            'mistral_model'       => AIAP_Model_Registry::default_text_model( 'mistral' ),
            // Groq
            'groq_api_key'        => '',
            'groq_model'          => AIAP_Model_Registry::default_text_model( 'groq' ),
            // DeepSeek
            'deepseek_api_key'    => '',
            'deepseek_model'      => AIAP_Model_Registry::default_text_model( 'deepseek' ),
            // OpenRouter
            'openrouter_api_key'  => '',
            'openrouter_model'    => AIAP_Model_Registry::default_text_model( 'openrouter' ),
            // システムプロンプト
            'system_prompt'       => '',
            'content_language'    => 'ja',
            // 0 = AI 任せ (文字数指示を一切送らない)
            // 数値を入れると、その値に応じた長さの指示が送られる:
            //   - 800 以下: 「approximately N、do NOT exceed」 (短文として上限重視)
            //   - 2000 以下: 「around N」 (目安)
            //   - 2000 超:  「at least N」 (最低字数として強く要求)
            'content_length'      => 0,
            // 投稿設定
            'post_status'         => 'draft',
            'post_category'       => 0,
            'post_author'         => 1,
            'enabled'             => 1,
            // 天気情報
            'weather_enabled'       => 0,
            'weather_location_name' => '',
            'weather_latitude'      => '',
            'weather_longitude'     => '',
            'weather_cache_minutes' => 60,
            // 類似回避
            'avoid_duplicate_topics' => 1,  // 無償: 直近20件のAI生成タイトルをプロンプトに含め、似た記事を書かないようAIに事前指示
            'dedup_enabled'       => 0,
            'dedup_step1'         => 1,
            'dedup_step2'         => 1,
            'dedup_max_retry'     => 3,
            'dedup_sensitivity'   => 'medium',
            // 自動実行(cron)関連
            // 取りこぼし復元(過去の未実行予定を後から拾うか)
            'cron_recover_missed'   => 1,
            // 復元する最大件数(1〜5)
            'cron_recover_max'      => 3,
            // SEO
            'seo_enabled'         => 0,
            'seo_meta_desc'       => 1,
            'seo_heading'         => 1,
            'seo_focus_kw'        => 1,
            'seo_faq'             => 0,
            'seo_toc'             => 0,
            'seo_template'        => 0,
            'seo_img_alt'         => 1,
            'seo_img_webp'        => 0,
            'seo_internal_link'   => 1,
            'seo_auto_tag'        => 1,
            'seo_related'         => 0,
            'seo_schema'          => 1,
            'seo_ogp'             => 0,
            'seo_canonical'       => 0,
            'seo_plugin_link'     => 0,
            'seo_reading_time'    => 0,
            'seo_sitemap_ping'    => 0,
            'seo_llms_txt'        => 0,
        );
    }

    /**
     * AI 設定のリポジトリ。
     *
     * オプション I/O・既定値マージ・API キーの暗号化/復号は AIAP_Settings に集約。
     *
     * @return AIAP_Settings
     */
    public static function settings() {
        return new AIAP_Settings(
            self::OPTION_KEY,
            array( __CLASS__, 'get_default_settings' ),
            AIAP_AI_Providers::get_key_fields()
        );
    }

    public static function get_settings() {
        return self::settings()->get_decrypted();
    }

    public static function update_settings( $settings ) {
        self::settings()->update_encrypted( is_array( $settings ) ? $settings : array() );
    }
    public static function get_language_options() {
        // セレクトボックス用の言語コード→ネイティブ表記
        // (AIへの指示には言語コードを使う。ネイティブ表記はUI表示のためだけ)
        // 世界の主要言語 約50。話者数の多い順をベースに、地域ごとに代表的な言語を網羅。
        return array(
            // アジア東部
            'ja'    => '日本語',
            'zh'    => '中文 (简体)',
            'zh-TW' => '繁體中文',
            'ko'    => '한국어',

            // 国際語
            'en'    => 'English',

            // ヨーロッパ主要
            'es'    => 'Español',
            'fr'    => 'Français',
            'de'    => 'Deutsch',
            'it'    => 'Italiano',
            'pt'    => 'Português',
            'pt-BR' => 'Português (Brasil)',
            'ru'    => 'Русский',
            'nl'    => 'Nederlands',
            'pl'    => 'Polski',
            'sv'    => 'Svenska',
            'no'    => 'Norsk',
            'da'    => 'Dansk',
            'fi'    => 'Suomi',
            'cs'    => 'Čeština',
            'sk'    => 'Slovenčina',
            'hu'    => 'Magyar',
            'ro'    => 'Română',
            'el'    => 'Ελληνικά',
            'bg'    => 'Български',
            'uk'    => 'Українська',

            // 中東・北アフリカ
            'ar'    => 'العربية',
            'he'    => 'עברית',
            'fa'    => 'فارسی',
            'tr'    => 'Türkçe',

            // 南アジア
            'hi'    => 'हिन्दी',
            'bn'    => 'বাংলা',
            'ur'    => 'اردو',
            'ta'    => 'தமிழ்',
            'te'    => 'తెలుగు',
            'mr'    => 'मराठी',
            'gu'    => 'ગુજરાતી',
            'pa'    => 'ਪੰਜਾਬੀ',

            // 東南アジア
            'id'    => 'Bahasa Indonesia',
            'ms'    => 'Bahasa Melayu',
            'th'    => 'ไทย',
            'vi'    => 'Tiếng Việt',
            'tl'    => 'Tagalog',
            'my'    => 'မြန်မာ',
            'km'    => 'ខ្មែរ',
            'lo'    => 'ລາວ',

            // アフリカ・その他
            'sw'    => 'Kiswahili',
            'af'    => 'Afrikaans',
            'am'    => 'አማርኛ',

            // 自由入力
            'other' => 'Other',
        );
    }

    const GENERATE_MAX_RETRY = 3;

    /**
     * generate() を最大N回までリトライするラッパー
     *
     * @param string $user_prompt
     * @param callable|null $on_retry function(int $attempt, WP_Error $err) — 各リトライ時にログ等を出すコールバック
     * @param array $overrides 一時的な上書き設定 ('content_language' 等)
     * @param bool  $is_manual  手動実行かどうか (true なら現在日時 override を参照しない)
     * @return array|WP_Error 最後の成功結果、または最終失敗のWP_Error
     */
    public static function generate_with_retry( $user_prompt, $on_retry = null, $overrides = array(), $is_manual = false ) {
        // 注: RSS 注入処理は generate() 側で行う（Quick Post の do_single_generate_and_check が
        // generate() を直接呼ぶ経路でも注入が効くようにするため）。

        $result = null;
        $current_prompt = (string) $user_prompt;
        // GENERATE_MAX_RETRY は「リトライ最大回数」。よって試行は最大 (N+1) 回(初回 + リトライ N 回)。
        for ( $attempt = 1; $attempt <= self::GENERATE_MAX_RETRY + 1; $attempt++ ) {
            // Bug fix (audit r9 self-audit): 画像 call_with_retry と同じ理由で、各
            // attempt の頭で実行時間制限をリセットする。AI 生成は wp_remote_post
            // timeout=120 秒 を最大 4 回 + sleep(1+2+3) かかるため、共用ホスト等の
            // max_execution_time=30 秒環境ではリトライ完走前に殺される。
            if ( function_exists( 'set_time_limit' ) ) {
                @set_time_limit( 0 );
            }

            $result = self::generate( $current_prompt, $overrides, $is_manual );
            if ( ! is_wp_error( $result ) ) {
                return $result;
            }

            // エラー種別を見てリトライ可否を判定。
            // 401/403/400 等の入力/権限エラーは再試行しても解消しないので即返却する。
            $err_data = $result->get_error_data();
            $http_code = is_array( $err_data ) && isset( $err_data['http_code'] ) ? (int) $err_data['http_code'] : 0;
            $retry_after = is_array( $err_data ) && isset( $err_data['retry_after'] ) ? (int) $err_data['retry_after'] : 0;

            // build_api_error_wp_error が retryable=false を立てている場合は即返却。
            // (429 のうち課金/クォータ枯渇など、コードだけでは判別できない永続エラー対応)
            if ( is_array( $err_data ) && array_key_exists( 'retryable', $err_data ) && ! $err_data['retryable'] ) {
                return $result;
            }

            // http_code=0 の場合 (HTTP 通信失敗 / parse_error 等) はリトライ可能扱い。
            // http_code が記録されていてリトライ不可コードなら即返却。
            // (retryable キーが入っていない経路の後方互換チェック)
            if ( $http_code !== 0 && ! AIAP_AI_Errors::is_retryable_http_code( $http_code ) ) {
                return $result;
            }

            // 最後の試行(リトライ N 回目を実行した直後)は次の試行がないので待機しない。
            // ループ上限 = GENERATE_MAX_RETRY + 1 (試行最大回数) なので、それ未満の時のみリトライ前処理を行う。
            if ( $attempt <= self::GENERATE_MAX_RETRY ) {
                if ( is_array( $err_data ) && ! empty( $err_data['retry_prompt'] ) ) {
                    $current_prompt = (string) $err_data['retry_prompt'];
                }
                if ( is_callable( $on_retry ) ) {
                    call_user_func( $on_retry, $attempt, $result );
                }
                // 待機秒数は AI_Client::compute_retry_sleep_seconds() に集約 (#35)。
                sleep( AIAP_AI_Errors::compute_retry_sleep_seconds( $attempt, $retry_after ) );
            }
        }

        // リトライ枯渇時のフォールバック: 最後の失敗が「文字数不足」で、かつ有効な
        // 生成結果 (fallback_result) を持っている場合は、その結果を採用する。
        // 文字数は目標未達だが本文・タイトルは揃っているため、「記事なし」より
        // 「やや短い記事」を公開する方を優先する。
        $fb = self::extract_short_content_fallback( $result );
        if ( $fb !== null ) {
            return $fb;
        }

        return $result;
    }

    /**
     * content_too_short の WP_Error からフォールバック生成結果を取り出す。
     *
     * リトライ枯渇時に「やや短いが有効な記事」を採用するための共通ヘルパ。
     *
     * @param mixed $result generate() / generate_with_retry() の戻り値
     * @return array|null フォールバック採用する生成結果、なければ null
     */
    public static function extract_short_content_fallback( $result ) {
        if ( ! is_wp_error( $result ) ) {
            return null;
        }
        $data = $result->get_error_data();
        if ( is_array( $data )
            && isset( $data['kind'] ) && $data['kind'] === 'content_too_short'
            && isset( $data['fallback_result'] ) && is_array( $data['fallback_result'] ) ) {
            return $data['fallback_result'];
        }
        return null;
    }

    /**
     * プロンプトを送信して、タイトルと本文を生成
     *
     * @param string $user_prompt
     * @param array  $overrides  一時的な上書き設定 ('content_language' 等)
     * @param bool   $is_manual  手動実行かどうか (true なら現在日時 override を参照しない)
     * @return array|WP_Error array( 'title' => ..., 'content' => ..., 'slug' => ... )
     */
    public static function generate( $user_prompt, $overrides = array(), $is_manual = false ) {
        // RSS 注入: overrides に rss_articles があれば Composer 経由でプロンプトを合成。
        // Quick Post (do_single_generate_and_check) と自動 (generate_with_retry) の
        // 両方の経路でここを通るため、注入は generate() に集約する。
        // $user_prompt はローカル変数で書き換えるので呼び出し元に影響しない。
        if ( is_array( $overrides )
             && ! empty( $overrides['rss_articles'] )
             && is_array( $overrides['rss_articles'] )
             && class_exists( 'AIAP_RSS_Composer' ) ) {
            $rss_mode    = isset( $overrides['rss_mode'] ) ? (string) $overrides['rss_mode'] : 'pick';
            $user_prompt = AIAP_RSS_Composer::inject( $user_prompt, $overrides['rss_articles'], $rss_mode );
        }

        $settings = self::get_settings();
        if ( ! empty( $overrides ) && is_array( $overrides ) ) {
            $settings = array_merge( $settings, $overrides );
        }
        $provider = $settings['provider'];
        // Bug fix (audit r4): SEO is a Pro feature; on a downgraded site a stale
        // seo_enabled=1 should not cause SEO directives + extra JSON fields (and thus
        // larger token usage) to be sent to the AI. Mirror the runtime Pro gate that
        // AIAP_SEO::apply() now enforces.
        $seo      = ! empty( $settings['seo_enabled'] )
            && ( ! class_exists( 'AIAP_License' ) || AIAP_License::is_pro() );

        // ユーザーがタスクで指定した言語コード(ja/en/zh/ko/es/other)
        $lang_code = isset( $settings['content_language'] ) ? $settings['content_language'] : 'ja';

        // 言語指示(英語固定)。AIは英語の指示でも、対象言語で出力できる
        $lang_hard_rule = sprintf(
            "**LANGUAGE REQUIREMENT (HIGHEST PRIORITY, MUST OBEY)**: "
            . "Write the article (title and content) in language code '%s'. "
            . "Even if the user prompt is written in another language, that is only reference material, not the output language. "
            . "Both 'title' and 'content' MUST be in language '%s' (slug may be English). "
            . "Ignoring this language directive is strictly forbidden.",
            $lang_code, $lang_code
        );

        // === [先頭ブロック] 英語固定 ===
        // 仕様順序: 1. 現在日時 2. 言語指示 3. 文字数指示(指定時のみ)
        $system_parts = array();
        $system_parts[] = self::build_current_datetime_context( $is_manual );
        $system_parts[] = $lang_hard_rule;

        // 天気情報をプロンプトへ注入。取得失敗時も投稿は止めず、天気断定禁止の指示を入れる。
        if ( class_exists( 'AIAP_Weather_Client' ) ) {
            $weather_context = AIAP_Weather_Client::build_prompt_context( $settings );
            if ( $weather_context !== '' ) {
                $system_parts[] = $weather_context;
            }
        }

        // 文字数指示はユーザが明示的に指定した場合のみ追加(0/未指定なら送らない)
        $length = isset( $settings['content_length'] ) ? (int) $settings['content_length'] : 0;
        if ( $length > 0 ) {
            // 出力言語と同じ言語で「N 文字」と AI に伝える。
            // 旧実装は英語固定 "approximately %d characters" のみで、AI が character を
            // 英語 1 バイト相当として解釈する結果、日本語(マルチバイト)では実出力が
            // 指定値の半分前後になる問題があった。言語別の指示文に切り替えて、
            // AI に「指定言語の文字単位」を明確に解釈させる。
            $system_parts[] = self::build_length_directive( $lang_code, $length );
        }

        // SEOシステム指示(英語ベース・言語ニュートラル)
        if ( $seo ) {
            $seo_instructions = array();
            if ( ! empty( $settings['seo_heading'] ) ) {
                $seo_instructions[] = 'Use H2 and H3 for headings to form a logical hierarchy. Do NOT use H1.';
            }
            if ( ! empty( $settings['seo_template'] ) ) {
                $seo_instructions[] = 'Follow this article structure: introduction -> 3-5 H2 sections -> conclusion.';
            }
            if ( ! empty( $settings['seo_faq'] ) ) {
                $seo_instructions[] = 'Add an FAQ section at the end with 3-5 Q&A pairs. Each question should be an H3 heading followed by its answer.';
            }
            if ( ! empty( $settings['seo_toc'] ) ) {
                $seo_instructions[] = 'Insert a Table of Contents (HTML list) at the top of the article body, with anchor links to each heading (use id attributes).';
            }
            if ( ! empty( $seo_instructions ) ) {
                $system_parts[] = "[SEO INSTRUCTIONS]\n" . implode( "\n", $seo_instructions );
            }
        }

        // 直近20件のAI生成投稿タイトルを取得し、類似タイトル回避指示を追加。
        // 既存の dedup (Pro機能) は生成後の類似度チェックで再生成を促すが、
        // ここは生成前にプロンプト側で「これらと似た記事は書かないで」と
        // 抑制することで、再生成コスト/トークン浪費を未然に減らす。
        // ユーザーが「タイトルで類似投稿を判定する」設定をオフにしている場合はスキップ。
        if ( ! empty( $settings['avoid_duplicate_topics'] ) ) {
            $recent_titles = self::get_recent_generated_titles( 20 );
            if ( ! empty( $recent_titles ) ) {
                $titles_list = "- " . implode( "\n- ", $recent_titles );
                $system_parts[] = "[AVOID DUPLICATE TOPICS]\n"
                    . "The following are titles of the 20 most recently generated posts on this site. "
                    . "Do NOT write an article whose title or main topic is the same as, or very similar to, any of these. "
                    . "Pick a clearly different angle, subject, or sub-topic.\n"
                    . $titles_list;
            }
        }

        if ( ! empty( $settings['system_prompt'] ) ) {
            $system_parts[] = $settings['system_prompt'];
        }
        $system_text = implode( "\n", $system_parts );

        // JSON出力フィールドを構築(英語ベース)
        // 設計意図: AI は前のフィールドから順に書く心理になり、content を最後に置くことで
        //          「最後に残った余裕で content を長く書く」誘導になる。逆に content を
        //          最初に置くと、AI がメタフィールドのトークン分も見越して content を
        //          短く切り上げる傾向がある。
        // メタフィールド (短いもの) を先に
        $json_fields = array(
            '"title": "article title"',
            '"slug": "english-slug-for-url"',
        );
        $json_notes = array(
            'slug must be an English URL slug. Use only lowercase letters, digits, and hyphens. 3-6 words is ideal.',
        );

        if ( $seo ) {
            if ( ! empty( $settings['seo_meta_desc'] ) ) {
                $json_fields[] = '"meta_description": "meta description, MUST be between 120 and 155 characters"';
                $json_notes[] = 'meta_description is required. It MUST contain at least 120 characters and no more than 155 characters. Summarize the article naturally; do not pad with filler words. If the article is short, expand by elaborating on the key takeaway in the same language as the body.';
            }
            if ( ! empty( $settings['seo_focus_kw'] ) ) {
                $json_fields[] = '"focus_keyword": "1-3 word focus keyword"';
                $json_notes[] = 'focus_keyword is the main keyword for the article. Include it naturally in the title, headings, and body.';
            }
            if ( ! empty( $settings['seo_auto_tag'] ) ) {
                $json_fields[] = '"tags": ["tag1", "tag2", "tag3"]';
                $json_notes[] = 'tags must be 3-5 WordPress tags relevant to the article.';
            }
            // Bug fix (audit r6): seo_faq 有効時は AI 本文に FAQ セクションを書かせると同時に、
            // 同じ Q&A を構造化 JSON でも返させる。これにより AIAP_SEO::apply() で FAQPage
            // JSON-LD を生成し、Google の FAQ 構造化データ要件を満たせる
            // (本文の H3 を正規表現で抽出する方式は誤検出が多いため、AI 出力を一次ソースとする)。
            if ( ! empty( $settings['seo_faq'] ) ) {
                $json_fields[] = '"faq": [{"question": "...", "answer": "..."}]';
                $json_notes[] = 'faq must mirror the FAQ section that you put in the body. 3-5 entries, each with a question and a concise answer (in the same language as the body). Do not include HTML in question or answer; plain text only.';
            }
            // image_alt はアイキャッチが実際に付く場合のみ意味を持つ。
            // effective が OFF (per-post で「付けない」を選んだケース) では出力させない。
            $image_settings_for_alt = class_exists( 'AIAP_Image_Client' ) ? AIAP_Image_Client::get_settings() : array();
            $effective_for_alt = isset( $overrides['image_enabled_effective'] )
                ? (bool) $overrides['image_enabled_effective']
                : ! empty( $image_settings_for_alt['image_enabled'] );
            if ( ! empty( $settings['seo_img_alt'] ) && $effective_for_alt ) {
                $json_fields[] = '"image_alt": "alt text for the featured image"';
            }
        }

        // AI画像生成が有効ならimage_promptを必須フィールドに追加
        // image_enabled_effective が overrides で渡っていればタスク/手動側の per-post 設定を優先する。
        $image_settings_for_prompt = class_exists( 'AIAP_Image_Client' ) ? AIAP_Image_Client::get_settings() : array();
        $image_source_active = isset( $image_settings_for_prompt['image_source'] ) ? $image_settings_for_prompt['image_source'] : '';
        $effective_image_enabled = isset( $overrides['image_enabled_effective'] )
            ? (bool) $overrides['image_enabled_effective']
            : ! empty( $image_settings_for_prompt['image_enabled'] );
        $is_ai_image_active = $effective_image_enabled
                            && in_array( $image_source_active, array( 'dalle3', 'imagen', 'stability', 'flux' ), true );
        if ( $is_ai_image_active ) {
            $json_fields[] = '"image_prompt": "detailed English image-generation prompt"';
            $json_notes[] = 'image_prompt is the English prompt for AI image generation. Describe the scene of the article body in detail (place, season, time of day, concrete objects). If the body mentions stationery, people, animals, or plants, include them in the English prompt. Do NOT include any text in the image.';
        }

        // RSS pick モード時: AI に「どの記事を選んだか」を JSON で返させるためのフィールドを追加。
        // user message 側 (rss-composer.php) でも指示しているが、system 側の "Return ONLY the
        // following JSON format" 指示が強いため、ここに含めないと AI が省略してしまう。
        // 結果として selected_rss_index=0 (= 未指定でデフォルト) となり、log_rss_pick_index_invalid
        // 警告が毎回出ていたバグの修正。
        $is_rss_pick = ! empty( $overrides['rss_articles'] )
                    && is_array( $overrides['rss_articles'] )
                    && ( ! isset( $overrides['rss_mode'] ) || (string) $overrides['rss_mode'] === 'pick' );
        if ( $is_rss_pick ) {
            $json_fields[] = '"selected_rss_index": 1';
            $json_notes[]  = 'selected_rss_index is the 1-based index (an integer between 1 and N inclusive) of the news article you chose from the candidate list above.';
        }

        // content フィールドは最後に追加 (JSON フィールド順序の心理誘導: メタデータを先に、
        // 本体を最後に書く流れにすると AI が content に時間とトークンを十分に割きやすい)。
        // 文字数指定がある場合は JSON フィールド説明にも最低/上限字数を組み込む。
        // 旧実装は「文字数指示は ARTICLE LENGTH ブロックのみ」と中立にしていたが、
        // 中立すぎて AI が下振れし指定値の半分前後しか書かない問題が出たため、
        // meta_description が "MUST be between 120 and 155 characters" で守られている
        // 前例に倣い、content 側にも MUST be between %d and %d を明示する。
        // 文字数未指定 (= 0) のときは旧実装通り中立記述。
        if ( $length > 0 ) {
            $content_min = (int) round( $length * 0.9 );
            $content_max = (int) round( $length * 1.1 );
            $json_fields[] = sprintf(
                '"content": "HTML body of the article, MUST be between %d and %d characters in the target output language"',
                $content_min,
                $content_max
            );
            $json_notes[] = sprintf(
                'content is the main article body in HTML. Length REQUIREMENT: at least %d and at most %d characters of the target output language (target %d). Outputs shorter than %d characters are not acceptable.',
                $content_min,
                $content_max,
                $length,
                $content_min
            );
        } else {
            $json_fields[] = '"content": "HTML body of the article"';
            $json_notes[] = 'content is the main article body in HTML.';
        }

        $instruction = "\n\nReturn ONLY the following JSON format. No preamble or postscript:\n"
                     . '{' . implode( ', ', $json_fields ) . '}'
                     . "\n\n" . implode( "\n", $json_notes );

        // === [末尾ブロック] 英語固定・念押し ===
        // 仕様: 先頭と同じ順序で再掲(日時 → 言語 → 文字数)
        $instruction .= "\n\n" . self::build_current_datetime_context( $is_manual );
        $instruction .= sprintf(
            "\n\n**FINAL LANGUAGE REMINDER**: 'title' and 'content' MUST be in language code '%s'. "
            . "Even if the user instructions above are written in another language, that does NOT change the output language. "
            . "Outputting in any language other than '%s' is forbidden.",
            $lang_code, $lang_code
        );
        if ( $length > 0 ) {
            // helper の $is_reminder=true で末尾用の短い念押し文を取得。
            // 末尾の REMINDER も言語別にしないと、recency bias で末尾優位の英語が AI に
            // 「character = 英語 1 バイト」と再解釈させ、先頭ブロック側の言語別化が
            // 打ち消されて出力が指定値の半分前後に戻る。両方とも同じマップから取得する。
            $instruction .= "\n\n" . self::build_length_directive( $lang_code, $length, true );
        }

        // Bug fix (audit r9 self-audit, #46): user メッセージ末尾の system_prompt 再掲を削除。
        // system_prompt は既に system メッセージに含めて送られている (L716-718)。
        // user メッセージの末尾に「must obey」付きで再掲すると、LLM の recency bias で
        // ユーザーが入力した主題 (user_prompt = 例: 「ゴルフ」) が末尾の system_prompt 再掲に
        // 押し負け、AI が主題を無視して system_prompt を優先してしまう問題があった。

        $full_prompt = $user_prompt . $instruction;

        // ========================================================
        // AI 送信記録: 「AI に何を送ったか」をログに残し、後からデバッグ可能にする。
        // - メッセージ: i18n キー 'log_ai_request_payload' で登録 (新形式)。
        //   翻訳ファイル (.po/.mo) で言語ごとに表示文を切り替えられる。
        // - context  : 第4引数 (extra_context) に連想配列で詳細を保存。
        //   ログ UI 側で <details> 展開表示する。
        // - 機密性 (CLAUDE.md L25): プロンプト本文の全文 DB 保存は「ユーザー入力だから良し」
        //   と整理してきたが、組織内の機密情報や API キー類が誤って system_prompt に
        //   混入した場合に 7 日間 DB 平文残置となる影響が大きいため、長さ + 先頭プレビュー
        //   (200 文字、UTF-8 セーフ) のみを保存する。デバッグには十分。
        // ========================================================
        if ( class_exists( 'AIAP_Logger' ) ) {
            $aiap_model_key = $provider . '_model';
            $aiap_model_val = isset( $settings[ $aiap_model_key ] ) ? (string) $settings[ $aiap_model_key ] : '';
            AIAP_Logger::add(
                'info',
                'log_ai_request_payload',
                array(), // params (sprintf 用) は不要
                array(
                    'provider'           => $provider,
                    'model'              => $aiap_model_val !== '' ? $aiap_model_val : 'unknown',
                    'output_token_limit' => AIAP_AI_Providers::get_output_token_limit( $settings, $provider, $aiap_model_val ),
                    'is_manual'          => $is_manual ? 'true' : 'false',
                    'system_text_len'    => AIAP_Compat::mb_strlen( $system_text ),
                    'system_text_head'   => AIAP_Compat::mb_substr( $system_text, 0, 200 ),
                    'user_prompt_len'    => AIAP_Compat::mb_strlen( $full_prompt ),
                    'user_prompt_head'   => AIAP_Compat::mb_substr( $full_prompt, 0, 200 ),
                )
            );
        }

        switch ( $provider ) {
            case 'claude':
                $raw = AIAP_AI_Providers::call_claude( $settings, $system_text, $full_prompt );
                break;
            case 'openai':
                $raw = AIAP_AI_Providers::call_openai( $settings, $system_text, $full_prompt );
                break;
            case 'gemini':
                $raw = AIAP_AI_Providers::call_gemini( $settings, $system_text, $full_prompt );
                break;
            case 'mistral':
            case 'groq':
            case 'deepseek':
            case 'openrouter':
                $raw = AIAP_AI_Providers::call_openai_compatible( $provider, $settings, $system_text, $full_prompt );
                break;
            default:
                /* translators: %s: provider key */
                return new WP_Error(
                    'invalid_provider',
                    sprintf( __( 'Unknown provider: %s', 'ai-auto-poster' ), $provider ),
                    // 永続エラー: プロバイダ設定ミス。リトライしても解消しない。
                    array( 'retryable' => false, 'kind' => 'invalid_provider' )
                );
        }

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

        if ( trim( (string) $raw ) === '' ) {
            return new WP_Error(
                'empty_ai_response',
                __( 'AI returned empty response before parsing.', 'ai-auto-poster' ),
                // タイミング依存の一時エラー: リトライで解消する可能性が高い。
                array( 'retryable' => true, 'kind' => 'empty_response' )
            );
        }

        // AI 生応答の記録: パース前の raw を長さ + 先頭/末尾プレビューで残す。
        // 「本文末尾に裸の `}` が出る」「途中で切れる」等のパース起因バグは、
        // 生応答が無いと原因経路 (truncation / JSON 破損 / フォールバック) を
        // 特定できないため。機密配慮 (CLAUDE.md L25) で全文は保存せず、長さと
        // 先頭/末尾各 200 文字 (UTF-8 セーフ・scrub 済み) のみ保存する。末尾を
        // 残すのは、JSON エンベロープの閉じ (`"}`) や途中切れの判定に末尾が要るため。
        if ( class_exists( 'AIAP_Logger' ) ) {
            $raw_str = (string) $raw;
            $raw_len = AIAP_Compat::mb_strlen( $raw_str );
            $tail    = AIAP_Compat::mb_substr( $raw_str, max( 0, $raw_len - 200 ), 200 );
            AIAP_Logger::add(
                'info',
                'log_ai_response_payload',
                array(),
                array(
                    'response_len'  => $raw_len,
                    'response_head' => AIAP_AI_Errors::scrub_secrets( AIAP_Compat::mb_substr( $raw_str, 0, 200 ) ),
                    'response_tail' => AIAP_AI_Errors::scrub_secrets( $tail ),
                )
            );
        }

        // タスク/手動側の per-post 設定 (image_enabled_effective) を parse_response にも引き継ぐ。
        // 本文中の「Image Prompt:」風記述を抽出するか否かの判定に使う。
        $effective_for_parse = isset( $overrides['image_enabled_effective'] )
            ? (bool) $overrides['image_enabled_effective']
            : null;

        $parsed = AIAP_AI_Response_Parser::parse_response( $raw, $effective_for_parse );
        if ( is_wp_error( $parsed ) ) {
            return $parsed;
        }

        // 生成後の文字数検証 (下振れ対策)。
        // 文字数指示はプロンプト側の「お願い」にすぎず、LLM は文字数カウントが
        // 本質的に苦手で、特に CJK では下振れしやすい。プロンプトを言語別化しても
        // 実出力が指定値を大きく下回るケースが残るため、実出力の文字数を
        // マルチバイト安全に数え、最低字数 (目標×0.9) を下回ったら retryable な
        // WP_Error を返して既存のリトライ機構 (generate_with_retry /
        // do_single_generate_and_check) に再生成させる。
        // 文字数未指定 (length<=0) のときは検証しない (AI 任せ)。
        // 上限超過は再生成しない: 「足りない」が主訴であり、長い分には実害が小さく、
        // 上振れで毎回再生成するとコスト/トークンを浪費するため。
        // リトライ失敗時は短い本文でも採用できるよう fallback_result を残す。
        // 同時に次回試行の成功率を上げるため、直前の短い本文を材料にした retry_prompt も
        // error data に載せ、generate_with_retry / Quick Post 側が専用リトライへ切り替える。
        $check_len = isset( $settings['content_length'] ) ? (int) $settings['content_length'] : 0;
        if ( $check_len > 0 ) {
            $content_min = (int) round( $check_len * 0.9 );
            // HTML タグを除いた可視文字数で判定する (build_length_directive と同じ
            // 「本文の文字数」の意味に合わせる。タグ文字を字数に数えない)。
            $body_text = wp_strip_all_tags( (string) $parsed['content'] );
            $body_len  = AIAP_Compat::mb_strlen( $body_text );
            if ( $body_len < $content_min ) {
                if ( class_exists( 'AIAP_Logger' ) ) {
                    AIAP_Logger::add(
                        'warning',
                        'log_content_too_short',
                        array( $body_len, $content_min, $check_len )
                    );
                }
                return new WP_Error(
                    'content_too_short',
                    sprintf(
                        /* translators: 1: actual character count, 2: minimum required, 3: target */
                        __( 'Generated content (%1$d chars) is shorter than the required minimum (%2$d chars, target %3$d). Retrying.', 'ai-auto-poster' ),
                        $body_len,
                        $content_min,
                        $check_len
                    ),
                    // 一時的な下振れ: 再生成で解消する可能性が高いため retryable。
                    // retry_prompt: 次回は不足分を埋める専用指示で全文を書き直させる。
                    // fallback_result: リトライ枯渇時に採用する有効な生成結果。
                    array(
                        'retryable'       => true,
                        'kind'            => 'content_too_short',
                        'retry_prompt'    => self::build_short_content_retry_prompt(
                            $user_prompt,
                            $parsed,
                            $body_len,
                            $content_min,
                            $check_len
                        ),
                        'fallback_result' => $parsed,
                    )
                );
            }
        }

        return $parsed;
    }

    /**
     * 文字数指示文を、出力対象言語と同じ言語で生成する。
     *
     * 旧実装は英語固定 "approximately %d characters" のみで、AI が "character" を
     * 英語 1 バイト相当として解釈する結果、日本語などのマルチバイト言語では
     * 実出力が指定値の半分前後に圧縮される問題があった。
     * 出力言語と同じ言語で「N 文字」と書くことで、AI に「指定言語の文字単位」を
     * 明確に解釈させる。
     *
     * さらに「approximately」だけでは AI が短い方向(下振れ)に逃げて指定値の
     * 半分前後しか書かないケースが続発したため、目標値に加えて
     * 最低字数 (N×0.9) と上限目安 (N×1.1) を明示し、出力レンジを物理的に
     * 縛る。meta_description が "MUST be between 120 and 155 characters" の
     * 範囲指定で守られている前例に倣う。
     *
     * 対応言語: ja / en / zh / ko / es / fr / de / it / pt。
     * マップに無い言語コード (例: 'other') は英語フォールバックを返すが、
     * ISO コードに頼らず「target output language の文字単位で」と自然言語で
     * 言語名 / 文字概念を補足する。
     *
     * メイン指示 (system 先頭ブロック) と REMINDER (user message 末尾ブロック) を
     * $is_reminder で切り替える。L455 コメントの設計通り「日時 → 言語 → 文字数」
     * 順を先頭/末尾の両方で再掲する recency bias 対策の構造を維持する。
     * 末尾だけ英語のまま残すと、結局末尾優位で AI が英語の character (= 1 byte)
     * として解釈してしまい、メイン側を言語別にしても効果が打ち消されるため、
     * 両方とも同じマップから取得する。
     *
     * sprintf 引数順: 1=目標値, 2=最低値, 3=上限目安。
     *
     * @param string $lang_code 出力言語コード (例: ja, en, zh, ko, es, fr, de, it, pt, other)
     * @param int    $length    目標文字数 (>0 を呼出側で保証)
     * @param bool   $is_reminder true で user message 末尾の REMINDER 用短文を返す
     * @return string AI に渡す指示文
     */
    private static function build_length_directive( $lang_code, $length, $is_reminder = false ) {
        // 下振れ防止の最低字数 (目標の 90%) と、上振れ防止の上限目安 (目標の 110%)。
        // round() は四捨五入。1200 → min=1080 / max=1320。
        $min = (int) round( $length * 0.9 );
        $max = (int) round( $length * 1.1 );

        // メイン (詳しい指示) と REMINDER (短い念押し) を 1 マップにまとめる。
        // 同じ言語の文言が 1 箇所に揃うため、追加翻訳や微修正時に main/reminder を
        // 突き合わせて確認しやすい (DRY)。
        // sprintf プレースホルダは位置指定 (%1$d=目標, %2$d=最低, %3$d=上限)。
        $directives = array(
            'ja' => array(
                'main'     => '**記事の長さ (必達)**: 記事本文は約 %1$d 文字で書いてください。本文の文字数は必ず %2$d 文字以上 %3$d 文字以下に収めてください。これより短い記事や大幅に長い記事は不可です。',
                'reminder' => '**文字数の念押し**: 本文は %2$d 文字以上 %3$d 文字以下 (目標 %1$d 文字)。必ずこの範囲に収めてください。',
            ),
            'en' => array(
                'main'     => '**ARTICLE LENGTH (REQUIRED)**: Write the article body to approximately %1$d characters. The body MUST contain at least %2$d characters and at most %3$d characters. Shorter or significantly longer outputs are not acceptable.',
                'reminder' => '**ARTICLE LENGTH REMINDER**: Body MUST be between %2$d and %3$d characters (target %1$d). Stay within this range.',
            ),
            'zh' => array(
                'main'     => '**文章长度 (必须达到)**: 文章正文请写约 %1$d 个字符。正文字符数必须在 %2$d 到 %3$d 之间。短于或显著超出此范围均不可接受。',
                'reminder' => '**字数提醒**: 正文必须在 %2$d 到 %3$d 字符之间 (目标 %1$d)。请保持在此范围内。',
            ),
            'ko' => array(
                'main'     => '**기사 길이 (필수)**: 기사 본문은 약 %1$d자로 작성해 주세요. 본문은 반드시 %2$d자 이상 %3$d자 이하여야 합니다. 이보다 짧거나 크게 초과하는 출력은 허용되지 않습니다.',
                'reminder' => '**글자 수 알림**: 본문은 반드시 %2$d자 이상 %3$d자 이하 (목표 %1$d자). 이 범위를 지켜 주세요.',
            ),
            'es' => array(
                'main'     => '**LONGITUD DEL ARTÍCULO (OBLIGATORIO)**: Escribe el cuerpo del artículo con aproximadamente %1$d caracteres. El cuerpo DEBE contener al menos %2$d caracteres y como máximo %3$d caracteres. No se aceptan salidas más cortas o significativamente más largas.',
                'reminder' => '**RECORDATORIO DE LONGITUD**: El cuerpo DEBE tener entre %2$d y %3$d caracteres (objetivo %1$d). Mantente dentro de este rango.',
            ),
            'fr' => array(
                'main'     => "**LONGUEUR DE L'ARTICLE (OBLIGATOIRE)**: Écrivez le corps de l'article à environ %1\$d caractères. Le corps DOIT contenir au moins %2\$d caractères et au plus %3\$d caractères. Les sorties plus courtes ou significativement plus longues ne sont pas acceptées.",
                'reminder' => "**RAPPEL DE LONGUEUR**: Le corps DOIT contenir entre %2\$d et %3\$d caractères (cible %1\$d). Restez dans cette plage.",
            ),
            'de' => array(
                'main'     => '**ARTIKELLÄNGE (PFLICHT)**: Schreiben Sie den Artikeltext mit etwa %1$d Zeichen. Der Artikeltext MUSS mindestens %2$d Zeichen und höchstens %3$d Zeichen enthalten. Kürzere oder deutlich längere Ausgaben sind nicht akzeptabel.',
                'reminder' => '**LÄNGEN-ERINNERUNG**: Der Artikeltext MUSS zwischen %2$d und %3$d Zeichen liegen (Ziel %1$d). Bleiben Sie in diesem Bereich.',
            ),
            'it' => array(
                'main'     => "**LUNGHEZZA DELL'ARTICOLO (OBBLIGATORIA)**: Scrivi il corpo dell'articolo con circa %1\$d caratteri. Il corpo DEVE contenere almeno %2\$d caratteri e al massimo %3\$d caratteri. Output più brevi o significativamente più lunghi non sono accettabili.",
                'reminder' => "**PROMEMORIA LUNGHEZZA**: Il corpo DEVE essere tra %2\$d e %3\$d caratteri (obiettivo %1\$d). Resta in questo intervallo.",
            ),
            'pt' => array(
                'main'     => '**TAMANHO DO ARTIGO (OBRIGATÓRIO)**: Escreva o corpo do artigo com aproximadamente %1$d caracteres. O corpo DEVE conter pelo menos %2$d caracteres e no máximo %3$d caracteres. Saídas mais curtas ou significativamente mais longas não são aceitas.',
                'reminder' => '**LEMBRETE DE TAMANHO**: O corpo DEVE ter entre %2$d e %3$d caracteres (alvo %1$d). Mantenha-se nesta faixa.',
            ),
        );

        $key = $is_reminder ? 'reminder' : 'main';

        // 地域サブタグの吸収: zh-TW / pt-BR / en-GB 等、地域付きコードは完全一致しないが、
        // 基底言語 (zh / pt / en) のディレクティブが使えるならそれを流用する。
        // 旧実装は完全一致のみで、zh-TW(繁體中文) や pt-BR が zh/pt の専用指示を持つのに
        // 英語フォールバックへ落ちていた (文字数指示文だけ英語になる不整合)。
        $resolved = $lang_code;
        if ( ! isset( $directives[ $resolved ] ) && strpos( (string) $lang_code, '-' ) !== false ) {
            $base = substr( $lang_code, 0, strpos( $lang_code, '-' ) );
            if ( isset( $directives[ $base ] ) ) {
                $resolved = $base;
            }
        }

        if ( isset( $directives[ $resolved ][ $key ] ) ) {
            return sprintf( $directives[ $resolved ][ $key ], $length, $min, $max );
        }

        // フォールバック: マップに無い言語 ('other' 等) は英語で書きつつ、ISO コードに
        // 頼らず「target output language の文字単位で」と自然言語で明示する。
        if ( $is_reminder ) {
            return sprintf(
                '**ARTICLE LENGTH REMINDER**: Body MUST be between %2$d and %3$d characters (target %1$d), counted in the natural character unit of the target output language.',
                $length, $min, $max
            );
        }
        return sprintf(
            '**ARTICLE LENGTH (REQUIRED)**: Write the article body to approximately %1$d characters, counted in the natural character unit of the target output language (e.g. full-width characters for CJK, letters for alphabetic languages). '
            . 'The body MUST contain at least %2$d characters and at most %3$d characters. Shorter or significantly longer outputs are not acceptable.',
            $length, $min, $max
        );
    }

    /**
     * 文字数不足時の再試行用プロンプトを構築する。
     *
     * 同じ元プロンプトをそのまま再送すると、mini 系モデルでは同じ短さを繰り返しやすい。
     * そこで直前の短い生成結果を材料に「不足分を補って全文を書き直す」専用指示へ変換する。
     *
     * @param string $original_prompt  元のユーザープロンプト
     * @param array  $generated        直前の生成結果 (title/content 等)
     * @param int    $actual_length    実際の可視文字数
     * @param int    $min_length       必須下限
     * @param int    $target_length    目標文字数
     * @return string
     */
    private static function build_short_content_retry_prompt( $original_prompt, array $generated, $actual_length, $min_length, $target_length ) {
        $base_prompt = self::strip_short_content_retry_wrapper( (string) $original_prompt );
        $title       = isset( $generated['title'] ) ? trim( wp_strip_all_tags( (string) $generated['title'] ) ) : '';
        $body_text   = isset( $generated['content'] ) ? trim( wp_strip_all_tags( (string) $generated['content'] ) ) : '';
        $excerpt     = $body_text !== '' ? AIAP_Compat::mb_substr( $body_text, 0, 600 ) : '';

        $addendum  = "[[AIAP_LENGTH_RETRY]]\n";
        $addendum .= "The previous draft was too short.\n";
        $addendum .= sprintf(
            "Actual visible body length: %1\$d characters. Required minimum: %2\$d characters. Target: %3\$d characters.\n",
            (int) $actual_length,
            (int) $min_length,
            (int) $target_length
        );
        $addendum .= "Rewrite the FULL article from scratch so that the body clearly exceeds the minimum and lands near the target.\n";
        $addendum .= "Do NOT just add one short paragraph. Expand the substance of the article.\n";
        $addendum .= "Keep the same topic and intent, but add concrete details, examples, explanations, and smoother transitions.\n";
        $addendum .= "Use a fuller structure: introduction, 3 or more body sections, and a conclusion.\n";
        $addendum .= "Each body section should contain multiple sentences with specific content, not filler.\n";
        $addendum .= "Strictly preserve the regular JSON output format used by the main generation pipeline.\n";
        if ( $title !== '' ) {
            $addendum .= "Previous short title: " . $title . "\n";
        }
        if ( $excerpt !== '' ) {
            $addendum .= "Previous short body excerpt:\n" . $excerpt . "\n";
        }
        $addendum .= "\n[[AIAP_ORIGINAL_PROMPT]]\n";

        return $addendum . $base_prompt;
    }

    /**
     * 文字数不足リトライ用の包みを剥がして元のプロンプトへ戻す。
     *
     * 再試行のたびに不足修正文を積み増すとプロンプトが肥大化するため、
     * 既に包まれている場合は元の指示だけを取り出す。
     *
     * @param string $prompt
     * @return string
     */
    private static function strip_short_content_retry_wrapper( $prompt ) {
        $marker = '[[AIAP_ORIGINAL_PROMPT]]';
        $pos    = strpos( $prompt, $marker );
        if ( $pos === false ) {
            return $prompt;
        }

        $base = substr( $prompt, $pos + strlen( $marker ) );
        return ltrim( (string) $base );
    }

    /**
     * 直近 N 件の AI 生成投稿タイトルを取得
     *
     * プロンプトに「これらと似た記事は書かないで」と指示するために使う。
     * 対象は _aiap_generated=1 (= AI Auto Poster が生成した) 投稿のみ。
     * 手動編集された他の記事はノイズになるため除外。
     *
     * @param int $limit 取得件数 (デフォルト 20)
     * @return array<int,string> 投稿日降順のタイトル配列 (空文字は除外)
     */
    private static function get_recent_generated_titles( $limit = 20 ) {
        $limit = max( 1, (int) $limit );

        $post_ids = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'posts_per_page' => $limit,
            'orderby'        => 'date',
            'order'          => 'DESC',
            'meta_query'     => array(
                array(
                    'key'   => '_aiap_generated',
                    'value' => '1',
                ),
            ),
            'fields'         => 'ids',
            'no_found_rows'  => true,
            'suppress_filters' => true,
        ) );

        $titles = array();
        foreach ( $post_ids as $pid ) {
            $title = trim( (string) get_the_title( $pid ) );
            if ( $title !== '' ) {
                $titles[] = $title;
            }
        }
        return $titles;
    }

    public static function extract_image_prompt_from_content( $content ) {
        if ( $content === '' ) {
            return array( 'prompt' => '', 'content' => $content );
        }
        // 「Image Prompt:」「画像プロンプト:」「画像プロンプト:」などを許容
        // 厳格化: AI が本文末尾に独立段落として書いた場合のみマッチ。
        // 本文の途中で「画像プロンプト機能を使えば〜」のような言及を誤検出して
        // 後続を全削除しないよう、以下の追加条件を課す:
        //   1. 直前は段落終端 (</p>, </div>, </section>, 改行、または冒頭)
        //   2. ラベル後の本文には HTML タグを含まない (生のプロンプト英文を期待)
        //   3. ラベル後の本文長は 300 文字以下 (本文の長文を誤検出しない)
        //
        // Bug fix (audit r4): 旧来の `[::]` 文字クラスは ASCII コロンが 2 つ重複しているだけで、
        //   全角コロン「:」(U+FF1A) を取り損ねていた。日本語スタイルの「画像プロンプト:」を
        //   正しく拾えるよう、Unicode コードポイントで明示する。
        $re = '/(?:^|<\/p>\s*|<\/div>\s*|<\/section>\s*|\n\s*\n)'
            . '[ \t]*(?:Image\s*Prompt|Image\s*prompt|画像プロンプト)\s*[:\x{FF1A}]\s*'
            . '([^<\n]{1,300})\s*\z/us';
        if ( preg_match( $re, $content, $m, PREG_OFFSET_CAPTURE ) ) {
            $prompt   = trim( wp_strip_all_tags( $m[1][0] ) );
            // 抽出されたプロンプトが空 or 異常に短い場合はスキップ
            if ( $prompt === '' || strlen( $prompt ) < 5 ) {
                return array( 'prompt' => '', 'content' => $content );
            }
            $offset   = $m[0][1];
            $length   = strlen( $m[0][0] );
            $new_content = substr( $content, 0, $offset ) . substr( $content, $offset + $length );
            $new_content = rtrim( $new_content );
            return array( 'prompt' => $prompt, 'content' => $new_content );
        }
        return array( 'prompt' => '', 'content' => $content );
    }

    /**
     * 現在日時のコンテキスト文字列を作る(AIのシステムプロンプトに埋め込む)
     *
     * WordPressのタイムゾーン設定(wp_timezone)に基づいて、
     * 年月日・曜日・時間帯・タイムゾーン名をAIに伝える。
     * 季節はAIが月+タイムゾーンから自己判定する前提で渡さない。
     *
     * @return string
     */
    /**
     * AIに送る現在日時情報(英語固定)
     *
     * 言語に依存しない: AIは英語の指示を理解した上で、ユーザーが指定した言語で記事を書く。
     *
     * @return string
     */
    private static function build_current_datetime_context( $is_manual = false ) {
        $tz_name  = function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : date_default_timezone_get();
        $tz       = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( $tz_name );

        // 取りこぼし後追い実行時は本来の予定日時を使う
        // (Scheduler::run() が AIAP_Poster::run_single() 直前にセットする)
        // 手動実行は取りこぼし対象ではないため override は参照しない
        // (cron と手動が同時走行した場合に、自動側の override を手動側が拾わないようにするため)
        $override = null;
        if ( ! $is_manual && class_exists( 'AIAP_Scheduler' ) ) {
            $raw = get_option( AIAP_Scheduler::OPTION_OVERRIDE_POSTDATE, array() );
            // OVERRIDE_POSTDATE の鮮度上限は AIAP_Poster::OVERRIDE_POSTDATE_MAX_AGE_SEC に一元管理。
            // 旧実装は 300 秒固定で AI/Poster 間に二重定義されていた(値ズレ事故の温床)。
            $max_age = class_exists( 'AIAP_Poster' ) ? AIAP_Poster::OVERRIDE_POSTDATE_MAX_AGE_SEC : 1800;
            if ( is_array( $raw )
                && ! empty( $raw['date_str'] )
                && ! empty( $raw['time_str'] )
                && isset( $raw['set_at'] )
                && ( time() - (int) $raw['set_at'] ) < $max_age
            ) {
                $override = $raw;
            }
        }

        try {
            if ( $override ) {
                $base = new DateTimeImmutable( $override['date_str'] . ' ' . $override['time_str'] . ':00', $tz );
            } else {
                $base = new DateTimeImmutable( 'now', $tz );
            }
        } catch ( Exception $e ) {
            $base = new DateTimeImmutable( 'now' );
        }

        $ymd     = $base->format( 'Y-m-d' );
        $hm      = $base->format( 'H:i' );     // 24h HH:MM 形式 (例: "12:30")
        $hour    = (int) $base->format( 'G' );
        $dow_num = (int) $base->format( 'w' ); // 0=Sun, 1=Mon, ..., 6=Sat

        // 曜日(英語固定)
        $dow_short = array( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' );
        $dow = isset( $dow_short[ $dow_num ] ) ? $dow_short[ $dow_num ] : '';

        // 時間帯12分類(英語固定)
        $period = self::hour_to_period_label_en( $hour );

        // AI 解釈揺れ対策: ラベル名だけでは AI が「noon ≒ 夕方近く」のように
        // 誤解する事例が報告されたため、HH:MM 形式の絶対時刻と「時間帯ラベル」を
        // 併記して曖昧さを排除する。さらに「時間帯ラベルは絶対時刻に従属する」
        // ことを明示し、AI が独自解釈で別の時間帯を書かないよう強く指示する。
        return "**CURRENT DATE & TIME (the moment this article is being written — match it)**:\n"
             . "- Date: {$ymd} ({$dow})\n"
             . "- Current local time (24h): {$hm}\n"
             . "- Time of day label: {$period}\n"
             . "- Timezone: {$tz_name}\n"
             . "The exact local time above ({$hm}) is authoritative; the time-of-day label is only a hint derived from it.\n"
             . "Use this to infer the correct season, climate, AND time of day.\n"
             . "The scene depicted in the article MUST take place at this exact time of day.\n"
             . "Do NOT describe scenes set at a different time of day (e.g., do not write an evening or sunset scene if the time above is around midday, and do not write a morning or daytime scene if the time is late night).\n"
             . "Do NOT describe seasons that contradict the current month.";
    }

    /**
     * 時刻(0-23)を英語の時間帯ラベル(12分類)に変換
     */
    private static function hour_to_period_label_en( $hour ) {
        if ( $hour >= 0  && $hour <= 3  ) return 'late night';
        if ( $hour === 4 )                return 'pre-dawn';
        if ( $hour === 5 )                return 'dawn';
        if ( $hour === 6 )                return 'early morning';
        if ( $hour >= 7  && $hour <= 9  ) return 'morning';
        if ( $hour >= 10 && $hour <= 11 ) return 'late morning';
        if ( $hour >= 12 && $hour <= 13 ) return 'noon';
        if ( $hour >= 14 && $hour <= 16 ) return 'afternoon';
        if ( $hour >= 17 && $hour <= 18 ) return 'evening';
        if ( $hour === 19 )               return 'dusk';
        if ( $hour >= 20 && $hour <= 22 ) return 'night';
        return 'midnight';
    }


    /**
     * スマートクォート等を標準ASCIIに正規化（JSONパース前の前処理）
     */
    public static function normalize_quotes( $text ) {
        // 注意: 鍵括弧「」(U+300C/U+300D) は日本語の本文で頻出する正常な記号のため、
        //       絶対に置換しない。過去に置換していた時代があり「本文中の「悩み」」が
        //       JSON の文字列終端として誤認されて本文が途中で打ち切られる重大バグを起こした。
        //       現在は ASCII の " " などタイプセット系の引用符のみを正規化する。
        $map = array(
            "\xE2\x80\x9C" => '"', // “ U+201C
            "\xE2\x80\x9D" => '"', // ” U+201D
            "\xE2\x80\x9E" => '"', // „ U+201E
            "\xE2\x80\x9F" => '"', // ‟ U+201F
            "\xE3\x80\x9D" => '"', // 〝 U+301D
            "\xE3\x80\x9E" => '"', // 〞 U+301E
            "\xEF\xBC\x82" => '"', // ＂ U+FF02
            "\xE2\x80\x98" => "'", // ‘ U+2018
            "\xE2\x80\x99" => "'", // ’ U+2019
            "\xE2\x80\x9A" => "'", // ‚ U+201A
            "\xE2\x80\x9B" => "'", // ‛ U+201B
        );
        return strtr( $text, $map );
    }

}
