<?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,
            // 類似回避
            '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;
        // 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( $user_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_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 ) );
            }
        }
        return $result;
    }

    /**
     * プロンプトを送信して、タイトルと本文を生成
     *
     * @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;

        // 文字数指示はユーザが明示的に指定した場合のみ追加(0/未指定なら送らない)
        $length = isset( $settings['content_length'] ) ? (int) $settings['content_length'] : 0;
        if ( $length > 0 ) {
            // ユーザーが指定した数値を素直に伝える。「at least」「MUST」など強い表現を使わず、
            // AI が指定値を上回る/下回る大きなズレを起こしにくい中立な表現を採用する。
            $system_parts[] = sprintf(
                "**ARTICLE LENGTH**: Write the article body to approximately %d characters. "
                . "Try to stay close to this target; significant deviation in either direction is not desired.",
                $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 );
            }
        }

        // 直近10件のAI生成投稿タイトルを取得し、類似タイトル回避指示を追加。
        // 既存の dedup (Pro機能) は生成後の類似度チェックで再生成を促すが、
        // ここは生成前にプロンプト側で「これらと似た記事は書かないで」と
        // 抑制することで、再生成コスト/トークン浪費を未然に減らす。
        $recent_titles = self::get_recent_generated_titles( 10 );
        if ( ! empty( $recent_titles ) ) {
            $titles_list = "- " . implode( "\n- ", $recent_titles );
            $system_parts[] = "[AVOID DUPLICATE TOPICS]\n"
                . "The following are titles of the 10 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 に時間とトークンを十分に割きやすい)。
        // ただし「long」「detailed」のような長文化を促す誘導は付けない。文字数指示は
        // 上の ARTICLE LENGTH ブロックでのみ行い、ここでは中立な記述に留める。
        $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 ) {
            $instruction .= sprintf(
                "\n\n**ARTICLE LENGTH REMINDER**: Aim for approximately %d characters in the article body.",
                $length
            );
        }

        // 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): $system_text / $full_prompt はプロンプト本文のみで
        //   API キー等の認証情報は含まれない (認証は AIAP_Http で HTTP ヘッダ経由のため)。
        //   ユーザーカスタム system_prompt の内容は記録されるが、これはユーザー自身が
        //   設定画面で入力した値であり、本人のサイトのログに残るだけ。
        // ========================================================
        if ( class_exists( 'AIAP_Logger' ) ) {
            $aiap_model_key = $provider . '_model';
            AIAP_Logger::add(
                'info',
                'log_ai_request_payload',
                array(), // params (sprintf 用) は不要
                array(
                    'provider'    => $provider,
                    'model'       => isset( $settings[ $aiap_model_key ] ) ? (string) $settings[ $aiap_model_key ] : 'unknown',
                    'is_manual'   => $is_manual ? 'true' : 'false',
                    'system_text' => $system_text,
                    'user_prompt' => $full_prompt,
                )
            );
        }

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

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

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

        return AIAP_AI_Response_Parser::parse_response( $raw, $effective_for_parse );
    }

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

        return "**CURRENT DATE & TIME (the moment this article is being written — match it)**:\n"
             . "- Date: {$ymd} ({$dow})\n"
             . "- Time of day: {$period}\n"
             . "- Timezone: {$tz_name}\n"
             . "Use this to infer the correct season, climate, AND time of day.\n"
             . "The scene depicted in the article MUST take place at the time of day specified above.\n"
             . "Do NOT describe scenes set at a different time of day (e.g., do not write a morning or daytime scene if the time of day above is 'late night' or 'midnight').\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 );
    }

}
