<?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['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 $result;
            }

            // 3回目: JSONデコード完全失敗時、正規表現で title/content を個別抽出(最終フォールバック)
            $extracted = self::extract_title_content_by_regex( $json_raw );
            if ( $extracted ) {
                return array(
                    'title'           => $extracted['title'],
                    'content'         => $extracted['content'],
                    'slug'            => self::generate_slug_fallback( $extracted['title'] ),
                    'meta_description' => self::ensure_meta_description_length( '', $extracted['content'] ),
                );
            }
        }

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

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

        return array(
            'title'   => 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;
    }

    /**
     * JSONパース完全失敗時の最終救済:
     * "title": "..." と "content": "..." を正規表現で個別抽出
     * 多少壊れたJSONでも記事本体を救う
     */
    private static function extract_title_content_by_regex( $json ) {
        // title: "..." (エスケープされた引用符に対応)
        if ( ! preg_match( '/"title"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"/s', $json, $tm ) ) {
            return null;
        }
        if ( ! preg_match( '/"content"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"/s', $json, $cm ) ) {
            return null;
        }
        $title   = self::decode_json_string_literal( $tm[1] );
        $content = self::decode_json_string_literal( $cm[1] );
        if ( $title === '' || $content === '' ) {
            return null;
        }
        return array( 'title' => $title, 'content' => $content );
    }

    /**
     * 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 ( mb_strlen( $meta ) > $max_len ) {
            return mb_substr( $meta, 0, $max_len );
        }

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

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

        // AI meta が空 → 本文だけを切り出す
        if ( $body_text !== '' ) {
            return 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 );
    }
}
