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

/**
 * AI 応答の JSON パース + 修復ロジック
 *
 * 元 includes/class-aiap-ai-client.php から分離。
 * LLM の不完全な JSON 出力を救済する低レベル処理群を集約。
 * 共通基盤 (normalize_quotes, extract_image_prompt_from_content, scrub_secrets)
 * は親クラス AIAP_AI_Client にあるため直接参照する。
 */
class AIAP_AI_Response_Parser {

    /**
     * AIの応答からtitle, content, slugを抽出
     */
    public static function parse_response( $raw, $override_image_enabled = null ) {
        $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $raw );
        $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned );
        $cleaned = trim( $cleaned );

        // 中括弧の対応バランスを取って最外周のJSONブロックを抽出
        // (greedy `\{[\s\S]*\}` だと content 内の {} が混じったときに壊れた範囲を切り出してしまう)
        $json_raw = self::extract_balanced_json( $cleaned );
        if ( $json_raw !== '' ) {
            $json_raw = AIAP_AI_Client::normalize_quotes( $json_raw );

            // AI 出力が max_tokens 上限などで途中打ち切りされた可能性があれば補完を試みる。
            // (末尾が '}' でなく、文字列リテラル途中のままの場合: 閉じ '"' と '}' を補う)
            $json_raw = self::repair_truncated_json( $json_raw );

            // 1回目: そのままデコード
            $data = json_decode( $json_raw, true );

            // 2回目: 文字列値内の生改行を \n エスケープに置換してリトライ
            // (AIが content の中で生改行を返すとJSONが壊れるケースに対応)
            if ( ! is_array( $data ) ) {
                $json_fixed = self::escape_raw_newlines_in_json_strings( $json_raw );
                $data = json_decode( $json_fixed, true );
            }

            // 3回目: 文字列値内のエスケープされていない `"` を `\"` に変換してリトライ
            // (AIが content 内の HTML属性で生の `"` を書いてしまうケースに対応:
            //   "content":"<a href="https://..."> ← この href 値の `"` が JSON を破壊する)
            if ( ! is_array( $data ) ) {
                $json_fixed = self::escape_raw_newlines_in_json_strings( $json_raw );
                $json_fixed = self::escape_raw_quotes_in_json_strings( $json_fixed );
                $data = json_decode( $json_fixed, true );
            }

            if ( is_array( $data ) && isset( $data['title'] ) && isset( $data['content'] ) ) {
                $slug = isset( $data['slug'] ) ? sanitize_title( $data['slug'] ) : self::generate_slug_fallback( $data['title'] );
                $result = array(
                    'title'   => (string) $data['title'],
                    'content' => (string) $data['content'],
                    'slug'    => $slug,
                );

                // SEOフィールド（存在すれば抽出）
                // meta_description は SEO プラグイン(Yoast/Rank Math 等)の警告を避けるため、
                // 120 文字以上 156 文字以下を保証する。AI 出力が短い場合は本文先頭から補完する。
                $ai_meta = isset( $data['meta_description'] ) ? trim( (string) $data['meta_description'] ) : '';
                $result['meta_description'] = self::ensure_meta_description_length( $ai_meta, $result['content'] );
                if ( isset( $data['focus_keyword'] ) ) {
                    $result['focus_keyword'] = (string) $data['focus_keyword'];
                }
                if ( isset( $data['tags'] ) && is_array( $data['tags'] ) ) {
                    // AI が稀にネスト配列やオブジェクトを返すことがあるため、文字列のみに絞ってからサニタイズする
                    // (PHP 8+ で sanitize_text_field に配列を渡すと TypeError になるため)
                    $string_tags = array_filter( $data['tags'], 'is_string' );
                    $result['tags'] = array_values( array_map( 'sanitize_text_field', $string_tags ) );
                }
                if ( isset( $data['faq'] ) && is_array( $data['faq'] ) ) {
                    $faq = self::sanitize_faq_entries( $data['faq'] );
                    if ( ! empty( $faq ) ) {
                        $result['faq'] = $faq;
                    }
                }
                if ( isset( $data['image_alt'] ) ) {
                    $result['image_alt'] = (string) $data['image_alt'];
                }
                // RSS pick モード: AI が返した記事インデックスを上位に伝搬する
                if ( isset( $data['selected_rss_index'] ) ) {
                    $result['selected_rss_index'] = (int) $data['selected_rss_index'];
                }
                // AIが書いた画像生成用プロンプト(英語)を受け取る
                // システムプロンプトに「Image Prompt: ...」等の画像プロンプト指示がある場合
                if ( isset( $data['image_prompt'] ) ) {
                    $result['image_prompt'] = trim( (string) $data['image_prompt'] );
                } elseif ( isset( $data['imagePrompt'] ) ) {
                    $result['image_prompt'] = trim( (string) $data['imagePrompt'] );
                } else {
                    // JSONキーになく、AI画像生成が有効な場合のみ content から抽出する。
                    // (AIの画像生成が無効な状況では、本文中の「Image Prompt:」風記述を
                    //  誤って削除しないようにする — 例: チュートリアル記事の本文)
                    // $override_image_enabled が non-null の場合はタスク/手動の per-post 設定を優先する。
                    $img_settings = class_exists( 'AIAP_Image_Client' ) ? AIAP_Image_Client::get_settings() : array();
                    $effective_enabled = is_null( $override_image_enabled )
                        ? ! empty( $img_settings['image_enabled'] )
                        : (bool) $override_image_enabled;
                    $is_ai_image  = $effective_enabled
                                 && in_array( ( $img_settings['image_source'] ?? '' ), array( 'dalle3', 'imagen', 'stability', 'flux' ), true );
                    if ( $is_ai_image ) {
                        $extracted_ip = AIAP_AI_Client::extract_image_prompt_from_content( $result['content'] );
                        if ( $extracted_ip['prompt'] !== '' ) {
                            $result['image_prompt'] = $extracted_ip['prompt'];
                            $result['content']      = $extracted_ip['content'];
                        }
                    }
                }

                return self::sanitize_parsed_result( $result );
            }

            // 3回目: JSONデコード完全失敗時、正規表現で title/content + 取れた任意メタを抽出(最終フォールバック)
            $extracted = self::extract_title_content_by_regex( $json_raw );
            if ( $extracted ) {
                $fallback = array(
                    'title'   => $extracted['title'],
                    'content' => $extracted['content'],
                    'slug'    => self::generate_slug_fallback( $extracted['title'] ),
                );
                // 取れた追加メタを反映 (壊れた JSON で SEO メタを失わないため)
                $meta_desc_raw = isset( $extracted['meta_description'] ) ? (string) $extracted['meta_description'] : '';
                $fallback['meta_description'] = self::ensure_meta_description_length( $meta_desc_raw, $extracted['content'] );
                foreach ( array( 'focus_keyword', 'image_alt', 'image_prompt' ) as $opt_key ) {
                    if ( isset( $extracted[ $opt_key ] ) && $extracted[ $opt_key ] !== '' ) {
                        $fallback[ $opt_key ] = $extracted[ $opt_key ];
                    }
                }
                if ( isset( $extracted['selected_rss_index'] ) ) {
                    $fallback['selected_rss_index'] = (int) $extracted['selected_rss_index'];
                }
                if ( isset( $extracted['tags'] ) && is_array( $extracted['tags'] ) ) {
                    $fallback['tags'] = $extracted['tags'];
                }
                if ( isset( $extracted['faq'] ) && is_array( $extracted['faq'] ) ) {
                    $fallback['faq'] = $extracted['faq'];
                }
                return self::sanitize_parsed_result( $fallback );
            }
        }

        // JSONっぽい応答をパース失敗したまま投稿に流すと事故になるので拒否。
        // 条件①: 先頭が '{' (=従来挙動)
        // 条件②: 応答テキスト内に "title" と "content" の両キーが共存している
        //   → AI が前置き文・思考過程・コードブロックを書いていても、本来 JSON を
        //     返したかったことが明らかなので、汎用フォールバック(1行目 title 化)に流さない。
        // (raw 全文は AI レスポンスにユーザー入力プロンプトや潜在的機密情報が含まれる
        //  可能性があるため、メッセージには切り詰めた scrub 済み版を、data には含めない)
        $has_json_keys = ( strpos( $cleaned, '"title"' ) !== false
                        && strpos( $cleaned, '"content"' ) !== false );
        if ( ( $cleaned !== '' && $cleaned[0] === '{' ) || $has_json_keys ) {
            $hint = AIAP_AI_Errors::scrub_secrets( AIAP_Compat::mb_substr( $raw, 0, 200 ) );
            /* translators: %s: short hint about which part of the response could not be parsed */
            return new WP_Error(
                'parse_error',
                sprintf( __( 'Failed to parse AI response (JSON-like): %s', 'ai-auto-poster' ), $hint ),
                // Bug fix (audit r12): JSON 途中切れ等のパース失敗は AI 側の一過性問題で
                // タイミング次第で次回成功するため、retryable=true を明示する。
                // 旧実装は data なしで返していたため、Quick Post 側の is_retryable 判定
                // (デフォルト false) で 1 回試行で諦めて終了するバグがあった。
                array( 'retryable' => true, 'kind' => 'parse_error' )
            );
        }

        // Fallback
        $lines = explode( "\n", trim( $raw ) );
        $title = trim( array_shift( $lines ) );
        $title = preg_replace( '/^#+\s*/', '', $title );
        $content = trim( implode( "\n", $lines ) );

        if ( $title === '' || $content === '' ) {
            $hint = AIAP_AI_Errors::scrub_secrets( AIAP_Compat::mb_substr( $raw, 0, 200 ) );
            /* translators: %s: short hint about which part of the response could not be parsed */
            return new WP_Error(
                'parse_error',
                sprintf( __( 'Failed to parse AI response: %s', 'ai-auto-poster' ), $hint ),
                // Bug fix (audit r12): L156 と同じ理由で retryable=true を明示。
                array( 'retryable' => true, 'kind' => 'parse_error' )
            );
        }

        return self::sanitize_parsed_result( array(
            'title'   => AIAP_Compat::mb_substr( $title, 0, 100 ),
            'content' => wpautop( $content ),
            'slug'    => self::generate_slug_fallback( $title ),
        ) );
    }

    /**
     * JSON 内の文字列値に含まれる「エスケープされていない `"`」を `\"` に置換する。
     *
     * 想定するケース: AI が content 値の HTML 属性で生 `"` を返した時。
     *   入力: '"content":"<a href="https://example.com">link</a>"'
     *   出力: '"content":"<a href=\"https://example.com\">link</a>"'
     *
     * アルゴリズム:
     *   1. 文字列値の開始位置を特定 (`"key":"` の `"` 直後)
     *   2. 文字列値の終端候補を後続の構造文字 (`",`, `"}`, `"]`) で判定
     *   3. 終端より前にある `"` で、エスケープされていないものを `\"` に変換
     *
     * シンプル化のため、JSON のキー名は ASCII で書かれている前提
     * (Freemius や Anthropic の API はすべて英語キーで返すため安全)。
     *
     * @param string $json
     * @return string
     */
    private static function escape_raw_quotes_in_json_strings( $json ) {
        // JSON のキーを検出: `"key":` または `"key" :` パターン
        // キー名は ASCII の英数字とアンダースコア、ハイフンのみと仮定
        // 値の終端は次のキーの開始 `,"key":` または閉じ括弧 `}` `]` で判定する
        $result = '';
        $len    = strlen( $json );
        $i      = 0;

        while ( $i < $len ) {
            // キー検出: `"key"\s*:\s*` パターン
            if ( $json[ $i ] === '"' && preg_match( '/\G"([a-zA-Z_][a-zA-Z0-9_-]*)"\s*:\s*/A', $json, $m, 0, $i ) ) {
                $result .= $m[0];
                $i += strlen( $m[0] );

                // 値が文字列の場合: `"` で始まる
                if ( $i < $len && $json[ $i ] === '"' ) {
                    $result .= '"';
                    $i++;
                    // 値の本体: 次の終端 `"\s*[,}\]]` まで
                    $value_buf = '';
                    while ( $i < $len ) {
                        $ch = $json[ $i ];
                        if ( $ch === '\\' && $i + 1 < $len ) {
                            // 既存のエスケープシーケンスはそのまま
                            $value_buf .= $ch . $json[ $i + 1 ];
                            $i += 2;
                            continue;
                        }
                        if ( $ch === '"' ) {
                            // 終端候補: 次の非空白文字が , } ] のいずれかなら終端
                            $j = $i + 1;
                            while ( $j < $len && ( $json[ $j ] === ' ' || $json[ $j ] === "\t" || $json[ $j ] === "\n" || $json[ $j ] === "\r" ) ) {
                                $j++;
                            }
                            if ( $j >= $len || $json[ $j ] === ',' || $json[ $j ] === '}' || $json[ $j ] === ']' ) {
                                // 文字列終端
                                $result .= $value_buf . '"';
                                $i++;
                                break;
                            }
                            // 内部の生 `"` → \" にエスケープ
                            $value_buf .= '\\"';
                            $i++;
                            continue;
                        }
                        $value_buf .= $ch;
                        $i++;
                    }
                    continue;
                }
                // 値が文字列でない場合 (数値、配列、オブジェクト、bool、null) は触らずに次の文字へ
                continue;
            }
            $result .= $json[ $i ];
            $i++;
        }

        return $result;
    }

    /**
     * JSON文字列の値部分に含まれる「生の改行・タブ」を \n \t にエスケープする
     * AIが {"content": "...実際の改行..."} のように返したときにJSONパースを救済する
     */
    private static function escape_raw_newlines_in_json_strings( $json ) {
        $result    = '';
        $len       = strlen( $json );
        $in_string = false;
        $escape    = false;

        for ( $i = 0; $i < $len; $i++ ) {
            $ch = $json[ $i ];

            if ( $escape ) {
                $result .= $ch;
                $escape = false;
                continue;
            }

            if ( $ch === '\\' ) {
                $result .= $ch;
                $escape = true;
                continue;
            }

            if ( $ch === '"' ) {
                $in_string = ! $in_string;
                $result .= $ch;
                continue;
            }

            if ( $in_string ) {
                if ( $ch === "\n" ) {
                    $result .= '\\n';
                } elseif ( $ch === "\r" ) {
                    $result .= '\\r';
                } elseif ( $ch === "\t" ) {
                    $result .= '\\t';
                } else {
                    $result .= $ch;
                }
            } else {
                $result .= $ch;
            }
        }

        return $result;
    }

    /**
     * パース結果 (title/content/...) の最終サニタイズ。
     *
     * parse_response の全戻り経路 (正常 JSON / regex フォールバック / 行分割
     * フォールバック) の共通出口として通す。目的は、壊れた/途中切れの JSON を
     * 救済する過程で content 末尾に紛れ込んだ JSON エンベロープの構造文字
     * (孤立した `}` / `"}`) を除去すること。スクリーンショットで本文末尾に裸の
     * `}` が表示される事故への対策。
     *
     * @param array $result title/content/... を含むパース結果
     * @return array サニタイズ済み結果
     */
    private static function sanitize_parsed_result( array $result ) {
        if ( ! isset( $result['content'] ) || ! is_string( $result['content'] ) ) {
            return $result;
        }
        $result['content'] = self::strip_trailing_json_artifact( $result['content'] );
        return $result;
    }

    /**
     * content 末尾に漏れた JSON エンベロープ残骸 (`}` / `"}`) を除去する。
     *
     * 誤除去防止 (コード記事の正当な `}` を守る): 末尾の孤立 `}` を剥がした後、
     * 本体の `{` と `}` の数が釣り合うときだけ採用する。釣り合わない場合は本文中の
     * 正当な `}` (コード片 `function(){...}` 等) を誤って消した可能性があるため
     * 元に戻す。HTML タグ内の JSON 例示 (`<pre>{...}</pre>`) も対で出現するため
     * 釣り合いチェックで保護される。
     *
     * @param string $content
     * @return string
     */
    private static function strip_trailing_json_artifact( $content ) {
        $trimmed = rtrim( $content );
        if ( $trimmed === '' ) {
            return $content;
        }

        // (1) 末尾に漏れた JSON エンベロープ残骸を剥がす。
        //   - 孤立した `}` / `]` / `"}` / `"]`
        //   - 後続フィールドごと飲み込んだ残骸 `... ","slug":"xxx"}` 等
        //     (content 終端の誤判定で発生。`","key":"値"` の連なり + 任意の閉じ)
        // いずれも「HTML タグ閉じ `>` か文末で本文が終わった後に続く JSON 構造」を狙う。
        $patterns = array(
            // 後続フィールド混入: `... ","keyname":"..."` (+任意で更に , 区切りの複数) + 末尾の閉じ } ] (必須)。
            // 閉じ必須にすることで、本文末尾の素のインライン JSON 風テキスト ("name":"taro" 等、閉じ無し)
            // を誤って削らない。エンベロープ残骸は必ず } / ] で閉じるため。
            '/\s*"\s*(?:,\s*"[a-zA-Z_][a-zA-Z0-9_-]*"\s*:\s*(?:"(?:[^"\\\\]|\\\\.)*"|\[[^\]]*\]|[^,}\]]+))+\s*[}\]]+\s*$/u',
            // 孤立した閉じ: 任意の " + 1個以上の } ] (+空白)
            '/\s*"?[}\]]+\s*$/u',
        );
        foreach ( $patterns as $re ) {
            $stripped = preg_replace( $re, '', $trimmed );
            if ( $stripped !== null && $stripped !== '' && $stripped !== $trimmed ) {
                // 誤除去防止: 剥がした後に本体の括弧が釣り合う (= 剥がしたのは
                // エンベロープ由来) ときのみ採用。コード片 `function(){...}` 等の
                // 正当な括弧は対で出現するため、誤って消すと不釣り合いになり却下される。
                if ( substr_count( $stripped, '{' ) === substr_count( $stripped, '}' )
                    && substr_count( $stripped, '[' ) === substr_count( $stripped, ']' ) ) {
                    $trimmed = $stripped;
                    break;
                }
            }
        }

        // 注: リテラルのエスケープ表記 (`\n` 等) の実体復元はここでは行わない。
        // 正常な json_decode 経路の content は既にエスケープ解決済みで、一律に
        // `\n`→改行 等を行うとコード/チュートリアル記事が本文に意図的に書いた 2 文字の
        // `\n` まで壊す (誤変換)。生エスケープが残るのは JSON デコードを経ない行分割
        // フォールバック経路のみのため、その復元は当該経路側で限定的に行う。

        return $trimmed === $content ? $content : $trimmed;
    }

    /**
     * JSONパース完全失敗時の最終救済:
     * "title": "..." と "content": "..." を正規表現で個別抽出
     * 多少壊れたJSONでも記事本体を救う
     *
     * 戻り値には title/content に加え、抽出できた meta_description/focus_keyword/
     * image_alt/image_prompt/selected_rss_index/tags も含める。これらが
     * 壊れた JSON で静かに欠落していた問題への対策 (SEO Pro 機能の劣化防止)。
     */
    private static function extract_title_content_by_regex( $json ) {
        // title: "..." (エスケープされた引用符に対応)
        // Bug fix (PCRE backtrack): 旧パターン `(?:[^"\\\\]|\\\\.)*` は巨大本文 + 多数の
        // バックスラッシュで pcre.backtrack_limit を超え、preg_match が false を返して
        // 記事破棄に至る恐れがあった。possessive 量子化 `*+` で後戻りを禁止して
        // バックトラック爆発を防ぐ。さらに preg_last_error() を確認し、爆発検知時は
        // null を返してフォールバック側でログ追跡できるようにする。
        if ( ! preg_match( '/"title"\s*:\s*"((?:[^"\\\\]|\\\\.)*+)"/s', $json, $tm ) ) {
            if ( function_exists( 'preg_last_error' ) && preg_last_error() !== PREG_NO_ERROR && class_exists( 'AIAP_Logger' ) ) {
                AIAP_Logger::add( 'warning', 'log_regex_backtrack_limit', array( 'title', (int) preg_last_error() ) );
            }
            return null;
        }
        if ( ! preg_match( '/"content"\s*:\s*"((?:[^"\\\\]|\\\\.)*+)"/s', $json, $cm ) ) {
            if ( function_exists( 'preg_last_error' ) && preg_last_error() !== PREG_NO_ERROR && class_exists( 'AIAP_Logger' ) ) {
                AIAP_Logger::add( 'warning', 'log_regex_backtrack_limit', array( 'content', (int) preg_last_error() ) );
            }
            return null;
        }
        $title   = self::decode_json_string_literal( $tm[1] );
        $content = self::decode_json_string_literal( $cm[1] );
        if ( $title === '' || $content === '' ) {
            return null;
        }
        $out = array( 'title' => $title, 'content' => $content );

        // 任意の追加メタ情報を best-effort で拾う。失敗時は素直に欠落させる。
        // Bug fix: AI が image_prompt をキャメルケース (imagePrompt) で返したとき、
        // JSON parse 経路は両対応していたが regex フォールバック側で欠落していた。
        // 正規表現キーで両形式を一括キャプチャし、out['image_prompt'] に統一する。
        $string_keys = array(
            'meta_description'    => 'meta_description',
            'focus_keyword'       => 'focus_keyword',
            'image_alt'           => 'image_alt',
            'image_prompt'        => 'image_prompt|imagePrompt',
        );
        foreach ( $string_keys as $out_key => $key_pattern ) {
            if ( preg_match( '/"(?:' . $key_pattern . ')"\s*:\s*"((?:[^"\\\\]|\\\\.)*+)"/s', $json, $m ) ) {
                $val = self::decode_json_string_literal( $m[1] );
                if ( $val !== '' ) {
                    $out[ $out_key ] = $val;
                }
            }
        }
        // 数値キー (1-based index): "selected_rss_index": 3
        if ( preg_match( '/"selected_rss_index"\s*:\s*(\d+)/', $json, $m ) ) {
            $out['selected_rss_index'] = (int) $m[1];
        }
        // tags は配列 (文字列要素): "tags": ["a","b"]
        // Bug fix: 旧 `\[[^\]]*\]` は要素文字列内の `]` で切れる
        // (例 "tags":["foo]bar","baz"] → ["foo] だけ抽出 → json_decode 失敗)。
        // 非貪欲 + 後続境界 lookahead で配列終端を安全に取得する。
        if ( preg_match( '/"tags"\s*:\s*(\[[\s\S]*?\])\s*(?=,|\})/s', $json, $m ) ) {
            $tags_decoded = json_decode( $m[1], true );
            if ( is_array( $tags_decoded ) ) {
                $tags_clean = array();
                foreach ( $tags_decoded as $t ) {
                    if ( is_string( $t ) && $t !== '' ) {
                        $tags_clean[] = $t;
                    }
                }
                if ( ! empty( $tags_clean ) ) {
                    $out['tags'] = $tags_clean;
                }
            }
        }
        if ( preg_match( '/"faq"\s*:\s*(\[[\s\S]*?\])\s*(?:,|\})/s', $json, $m ) ) {
            $faq_decoded = json_decode( $m[1], true );
            if ( is_array( $faq_decoded ) ) {
                $faq = self::sanitize_faq_entries( $faq_decoded );
                if ( ! empty( $faq ) ) {
                    $out['faq'] = $faq;
                }
            }
        }
        return $out;
    }

    /**
     * Sanitize AI-returned FAQ entries for FAQPage schema generation.
     *
     * @param array $faq Raw rows from AI JSON.
     * @return array<int,array{question:string,answer:string}>
     */
    private static function sanitize_faq_entries( array $faq ) {
        $out = array();
        foreach ( $faq as $row ) {
            if ( ! is_array( $row ) ) {
                continue;
            }
            $question = isset( $row['question'] ) ? sanitize_text_field( wp_strip_all_tags( (string) $row['question'] ) ) : '';
            $answer   = isset( $row['answer'] ) ? sanitize_text_field( wp_strip_all_tags( (string) $row['answer'] ) ) : '';
            if ( $question === '' || $answer === '' ) {
                continue;
            }
            $out[] = array(
                'question' => $question,
                'answer'   => $answer,
            );
        }
        return $out;
    }

    /**
     * JSON文字列の中身(外側の引用符を除いた部分)を JSON仕様に従って復元する。
     *
     * stripcslashes は C言語のエスケープのみ対応で、JSON固有の `\uXXXX`(Unicode)
     * を復元できない。例: "あ" は stripcslashes だと "u3042" のまま壊れる。
     * json_decode に通すことで、\uXXXX・サロゲートペア・標準JSONエスケープを
     * すべて正しく復元する。
     *
     * 失敗時(不正な `\X` シーケンス等)は従来の stripcslashes にフォールバックし、
     * 既存挙動の互換性を保つ。
     *
     * @param string $raw 正規表現でキャプチャした JSON文字列の中身
     * @return string 復元後の文字列
     */
    private static function decode_json_string_literal( $raw ) {
        // 生制御文字(JSON文字列内では仕様上不正)を事前にエスケープしておかないと
        // json_decode が失敗する。正規表現は [^"\\] を許容しているため $raw に
        // 生改行・生タブが含まれる可能性がある。
        $escaped = strtr( $raw, array(
            "\n" => '\\n',
            "\r" => '\\r',
            "\t" => '\\t',
        ) );
        $decoded = json_decode( '"' . $escaped . '"' );
        if ( is_string( $decoded ) ) {
            return $decoded;
        }
        // フォールバック: 不正な `\X` シーケンスがある等で json_decode 失敗時は
        // 旧挙動の stripcslashes で救う(\uXXXX は失われるが、ASCII本文は救える)。
        return stripcslashes( $raw );
    }

    /**
     * meta_description の長さを 120-156 文字に保証する。
     *
     * - 156 文字超 → 切り詰め
     * - 120 文字未満 → 本文先頭(strip_tags 済)から補完して 120 文字以上 156 文字以下に整える
     * - 完全に空 → 本文先頭から 120-156 文字を切り出して返す
     *
     * SEO プラグイン(Yoast / Rank Math)が「meta description が短すぎる」と警告する
     * 推奨下限は概ね 120 文字なので、それを下回らないようにする。
     *
     * @param string $ai_meta AI が返した meta_description(trim 済を想定)
     * @param string $content 本文 HTML(タグはここで除去する)
     * @return string 120 〜 156 文字に整えられた meta_description
     */
    private static function ensure_meta_description_length( $ai_meta, $content ) {
        $min_len = 120;
        $max_len = 156;

        $body_text = trim( wp_strip_all_tags( (string) $content ) );
        // 改行・連続空白を 1 個の半角スペースに正規化(meta description で改行が無いことを保証)
        $body_text = preg_replace( '/\s+/u', ' ', $body_text );

        $meta = (string) $ai_meta;

        // 156 文字超は切り詰め
        if ( AIAP_Compat::mb_strlen( $meta ) > $max_len ) {
            return AIAP_Compat::mb_substr( $meta, 0, $max_len );
        }

        // 120 文字以上ならそのまま採用
        if ( AIAP_Compat::mb_strlen( $meta ) >= $min_len ) {
            return $meta;
        }

        // 短すぎる(空も含む): 本文先頭から補完
        if ( $meta !== '' && $body_text !== '' ) {
            // 既存の AI meta + 区切り + 本文 で合成して切り出す
            $combined = $meta . ' ' . $body_text;
            return AIAP_Compat::mb_substr( $combined, 0, $max_len );
        }

        // AI meta が空 → 本文だけを切り出す
        if ( $body_text !== '' ) {
            return AIAP_Compat::mb_substr( $body_text, 0, $max_len );
        }

        // 本文も空(極めて稀): AI が返した値をそのまま返す(空でも構わない、SEO 機能側で扱う)
        return $meta;
    }

    /**
     * フォールバックスラグ生成
     */
    private static function generate_slug_fallback( $title ) {
        if ( preg_match( '/^[a-zA-Z0-9\s\-]+$/', $title ) ) {
            return sanitize_title( $title );
        }
        return 'post-' . wp_date( 'Ymd-His' );
    }

    /**
     * AI 出力が max_tokens 上限などで途中打ち切りされた JSON を補修する。
     *
     * 想定するケース:
     *   入力: '{"title":"...", "content":"記事の途中で切れて'   ← 末尾が文字列途中
     *   出力: '{"title":"...", "content":"記事の途中で切れて"}' ← 補完済み
     *
     * 既に閉じている JSON はそのまま返す(副作用なし)。
     *
     * @param string $json
     * @return string
     */
    private static function repair_truncated_json( $json ) {
        $len       = strlen( $json );
        if ( $len === 0 ) {
            return $json;
        }
        // Bug fix (audit r4): 旧実装は depth カウンタしか持たず `[` と `{` を同等に扱い、
        // 閉じも一律 `}` で append していた。たとえば `{"a":[1,2,` は depth=2 で終わるが
        // `}}` を補うと `{"a":[1,2,}}` となり依然 JSON 不正。今回 `{`/`[` をスタックに積み、
        // 閉じる時は対応する `}`/`]` を使う実装に置き換える。
        $stack     = array(); // 開いた括弧の対応する閉じ括弧を順に積む
        $in_string = false;
        $escape    = false;
        $negative_close = false; // 余分な閉じ括弧を検出した(=破損)

        for ( $i = 0; $i < $len; $i++ ) {
            $ch = $json[ $i ];
            if ( $escape ) { $escape = false; continue; }
            if ( $in_string ) {
                if ( $ch === '\\' ) { $escape = true; continue; }
                if ( $ch === '"' )  { $in_string = false; }
                continue;
            }
            if ( $ch === '"' ) { $in_string = true; continue; }
            if ( $ch === '{' ) { $stack[] = '}'; }
            elseif ( $ch === '[' ) { $stack[] = ']'; }
            elseif ( $ch === '}' || $ch === ']' ) {
                if ( empty( $stack ) ) {
                    $negative_close = true;
                } else {
                    array_pop( $stack );
                }
            }
        }

        // 完全に閉じている: 補修不要
        if ( ! $in_string && empty( $stack ) && ! $negative_close ) {
            return $json;
        }

        // 余分な閉じ括弧で構造が破綻している: 補修不能、原文を返して下流の json_decode に
        // 失敗扱いを任せる(無理に閉じ括弧を追加するとさらに壊れるため)。
        if ( $negative_close ) {
            return $json;
        }

        $repaired = $json;

        // 末尾の不完全な \ エスケープを除去 (例: "...test\" の "\")
        if ( substr( $repaired, -1 ) === '\\' ) {
            $repaired = substr( $repaired, 0, -1 );
        }

        // 文字列の途中で切れている: 閉じ引用符を補う
        if ( $in_string ) {
            $repaired .= '"';
        }

        // 文字列直後の不完全なトークン(例: `...,` や `...:`)を末尾から取り除く。
        // これがないと `{"a":[1,2,` を `]}` で閉じても `[1,2,]` となり JSON 不正。
        // ただし数値・true/false/null・"閉じ" は触らない(回数判定が複雑になるため)。
        $repaired = preg_replace( '/[,:]\s*$/', '', $repaired );

        // 開いたままの { や [ をスタックの逆順に閉じる(対応する括弧を使う)
        while ( ! empty( $stack ) ) {
            $repaired .= array_pop( $stack );
        }

        return $repaired;
    }

    /**
     * テキスト中の最初の '{' から、対応する '}' までを抽出する。
     * 文字列リテラル内の {} は無視し、エスケープも考慮するため、
     * greedy `/\{[\s\S]*\}/` のように余計な末尾までつかまない。
     *
     * @param string $text
     * @return string 抽出した JSON 候補文字列。見つからなければ ''。
     */
    private static function extract_balanced_json( $text ) {
        // ① 応答内の '"title"' を含む '{...}' ブロックを優先採用する。
        //   (AI が前置き文や Python コードを返した場合、その中の '{var}' を
        //    最初に見つけて誤抽出するのを防ぐ。'"content"' の同居も確認)
        $offset = 0;
        while ( ( $pos = strpos( $text, '"title"', $offset ) ) !== false ) {
            $brace = strrpos( substr( $text, 0, $pos ), '{' );
            if ( $brace !== false ) {
                $extracted = self::scan_balanced_from( $text, $brace );
                if ( $extracted !== '' && strpos( $extracted, '"content"' ) !== false ) {
                    return $extracted;
                }
            }
            $offset = $pos + 1;
        }
        // ② フォールバック: 最初の '{' から対応バランスを取る(従来挙動)
        $start = strpos( $text, '{' );
        if ( $start === false ) {
            return '';
        }
        return self::scan_balanced_from( $text, $start );
    }

    /**
     * 指定オフセットの '{' から対応する '}' までを切り出すスキャナ。
     * extract_balanced_json から共有される下請け処理。
     */
    private static function scan_balanced_from( $text, $start ) {
        if ( $start === false || $start < 0 ) {
            return '';
        }
        $len = strlen( $text );
        if ( $start >= $len ) {
            return '';
        }
        $depth     = 0;
        $in_string = false;
        $escape    = false;
        for ( $i = $start; $i < $len; $i++ ) {
            $ch = $text[ $i ];
            if ( $escape ) { $escape = false; continue; }
            if ( $in_string ) {
                if ( $ch === '\\' ) { $escape = true; continue; }
                if ( $ch === '"' )  { $in_string = false; }
                continue;
            }
            if ( $ch === '"' ) { $in_string = true; continue; }
            if ( $ch === '{' ) { $depth++; }
            elseif ( $ch === '}' ) {
                $depth--;
                if ( $depth === 0 ) {
                    return substr( $text, $start, $i - $start + 1 );
                }
            }
        }
        // 不対応(打ち切り等で } に到達しなかった): 開始位置から末尾までを返す。
        // この後、parse_response 側で repair_truncated_json() による閉じ括弧補完が行われる。
        return substr( $text, $start );
    }
}
