<?php

if ( ! defined( 'ABSPATH' ) ) {

    exit;

}



/**

 * SEO最適化処理クラス

 *

 * AI生成結果を受けて、投稿にSEO関連のメタデータ・構造化データ等を適用する。

 * 各機能は設定で個別にON/OFF可能。

 */

class AIAP_SEO {



    /**

     * フロントエンド出力フックを登録

     */

    public static function init_hooks() {

        add_action( 'wp_head', array( __CLASS__, 'output_schema' ), 1 );

        add_action( 'wp_head', array( __CLASS__, 'output_ogp' ), 2 );

        add_action( 'wp_head', array( __CLASS__, 'output_canonical' ), 3 );



        // /llms.txt を動的配信(ファイルを書き込まず、リクエスト時に生成)

        // WordPress 自身の robots.txt と同じ方式。WordPress.org ガイドライン準拠のため

        // WP コアディレクトリ(ABSPATH)へのファイル書き込みは行わない。

        add_action( 'parse_request', array( __CLASS__, 'maybe_serve_llms_txt' ) );



        // 旧バージョン(1.0.0 以前)で書き込まれた物理 llms.txt を削除

        add_action( 'admin_init', array( __CLASS__, 'cleanup_legacy_llms_txt' ) );



        // 新規投稿時に llms.txt キャッシュをクリア

        add_action( 'save_post', array( __CLASS__, 'clear_llms_txt_cache' ), 10, 1 );



        // 関連記事/内部リンクは post_content に書き込まず、表示時に the_content フィルタで注入する

        // (Gutenberg ブロック構造を破壊しないため。優先度 20 はテーマやショートコード処理の後に動く慣例値)

        add_filter( 'the_content', array( __CLASS__, 'filter_the_content' ), 20 );

    }



    /**

     * SEO処理を投稿に適用

     *

     * @param int   $post_id  投稿ID

     * @param array $result   AI生成結果

     * @param array $settings AIAP設定

     */

    public static function apply( $post_id, $result, $settings ) {

        // Bug fix (audit r4): SEO is a Pro feature. handle_save_settings only writes

        // seo_* fields when is_pro(), but a user who was Pro and downgraded retains a

        // saved seo_enabled=1. Mirror the Pro gate at runtime so SEO does not silently

        // keep applying after license downgrade.

        if ( empty( $settings['seo_enabled'] ) ) {

            return;

        }

        if ( class_exists( 'AIAP_License' ) && ! AIAP_License::is_pro() ) {

            return;

        }



        // 投稿への変更は最後に 1 回 wp_update_post でまとめる(post_modified の連続上書き、

        // および Yoast/Rank Math 等の save_post フック二重起動を避ける)

        $post_changes = array();



        // メタディスクリプション → 抜粋(集約バッファに積む)

        if ( ! empty( $settings['seo_meta_desc'] ) && ! empty( $result['meta_description'] ) && is_string( $result['meta_description'] ) ) {

            $post_changes['post_excerpt'] = sanitize_text_field( $result['meta_description'] );

            update_post_meta( $post_id, '_aiap_meta_description', sanitize_text_field( $result['meta_description'] ) );

        }



        // フォーカスキーワード

        if ( ! empty( $settings['seo_focus_kw'] ) && ! empty( $result['focus_keyword'] ) && is_string( $result['focus_keyword'] ) ) {

            update_post_meta( $post_id, '_aiap_focus_keyword', sanitize_text_field( $result['focus_keyword'] ) );

        }



        // タグの自動生成

        // 第3引数 false で「置換」: 毎回違うタグを返されてもタグが無限に増えない。

        // (既存タグを保持したい場合は seo_auto_tag を OFF にしてユーザー手動で管理してもらう)

        if ( ! empty( $settings['seo_auto_tag'] ) && ! empty( $result['tags'] ) && is_array( $result['tags'] ) ) {

            $clean_tags = array();

            foreach ( $result['tags'] as $tg ) {

                if ( is_string( $tg ) || is_numeric( $tg ) ) {

                    $clean_tags[] = sanitize_text_field( (string) $tg );

                }

            }

            if ( ! empty( $clean_tags ) ) {

                wp_set_post_tags( $post_id, $clean_tags, false );

            }

        }



        // 画像alt属性

        if ( ! empty( $settings['seo_img_alt'] ) && ! empty( $result['image_alt'] ) && is_string( $result['image_alt'] ) ) {

            $thumb_id = get_post_thumbnail_id( $post_id );

            if ( $thumb_id ) {

                update_post_meta( $thumb_id, '_wp_attachment_image_alt', sanitize_text_field( $result['image_alt'] ) );

            }

        }



        // 画像WebP変換 + リサイズ + 圧縮: AI Auto Poster の画像クライアントが

        // アップロード時に処理する (image-client.php postprocess_image_file)。

        // ここでは何もしない (旧 convert_thumbnail_webp は廃止)。



        // Bug fix (audit r9): 旧 r6 実装で seo_heading と seo_internal_link を両方 ON にすると、

        // それぞれが独立に get_post_field('post_content') で DB から再取得し、独立に

        // $post_changes['post_content'] へ上書きしていたため、後勝ちで H1 降格が消える順序バグが

        // あった。$post_changes に既に編集中の content があればそれを連鎖して使うことで、

        // 全ての本文編集が累積するようにする。読了時間計測も同じ「編集中の本文」を見るべき

        // (将来の処理追加でも同じ問題が出ないようヘルパ化)。

        $get_working_content = function () use ( &$post_changes, $post_id ) {

            if ( isset( $post_changes['post_content'] ) ) {

                return (string) $post_changes['post_content'];

            }

            return (string) get_post_field( 'post_content', $post_id );

        };



        // Bug fix (audit r6): seo_heading ON 時は AI のシステムプロンプトで「H1 を使うな」と

        // 指示しているが、AI が指示を破ることがある。投稿タイトルが既に H1 として出る前提で

        // 本文中の H1 を H2 に降格させる保険を入れる。Gutenberg の <!-- wp:heading {"level":1} -->

        // ブロックコメントも併せて level:2 に書き換える。

        if ( ! empty( $settings['seo_heading'] ) ) {

            $original_content = $get_working_content();

            $demoted = self::demote_h1_headings( $original_content );

            if ( $demoted !== $original_content ) {

                $post_changes['post_content'] = $demoted;

            }

        }



        // 読了時間（スキーマより先に計算）

        // Bug fix (audit r6): 旧実装は「mb_strlen / 500」固定で、日本語の読書速度

        // (約500文字/分) に合わせていた。英語サイトでは 500 文字 ≒ 100 単語 ≒ 24 秒で

        // 読める計算になり、実態より読了時間が長く出ていた。記事の言語コードに応じて

        // 計測方式を切り替える:

        //   - CJK (ja/zh/ko): 文字数 / 500 (文字単位の読書速度)

        //   - その他          : 単語数 / 220   (英文の標準的な読書速度 200-250 wpm の中央値)

        if ( ! empty( $settings['seo_reading_time'] ) ) {

            $content    = $get_working_content();

            $plain      = wp_strip_all_tags( (string) $content );

            $lang_code  = isset( $settings['content_language'] ) ? (string) $settings['content_language'] : 'ja';

            $is_cjk     = in_array( $lang_code, array( 'ja', 'zh', 'ko' ), true );

            if ( $is_cjk ) {

                $units_per_minute = 500;

                $units            = AIAP_Compat::mb_strlen( $plain );

            } else {

                $units_per_minute = 220;

                // str_word_count は ASCII ベースなので、UTF-8 マルチバイト言語では低めに出るが、

                // 英文/欧州言語では十分実用的。0 になった場合は文字数フォールバック。

                $units = (int) str_word_count( $plain );

                if ( $units === 0 ) {

                    $units            = AIAP_Compat::mb_strlen( preg_replace( '/\s+/u', '', $plain ) );

                    $units_per_minute = 500; // フォールバック (CJK 同等の文字数ベース)

                }

            }

            $reading = max( 1, (int) ceil( $units / max( 1, $units_per_minute ) ) );

            update_post_meta( $post_id, '_aiap_reading_time', $reading );

        }



        // Article JSON-LDスキーマ

        // Bug fix (audit r6): seo_faq 有効 + AI が faq を返した場合は FAQPage Schema を併存

        // させる。@graph 形式で 1 つの <script type="application/ld+json"> に Article と

        // FAQPage を入れることで、Google が両方のリッチリザルト要件を判定できる。

        // Bug fix: 旧実装は build_article_schema 内で get_post()->post_content を直接読んでおり、

        // apply() ループ中で組み立て中の post_content (H1 降格・浮きリンク除去後) が反映されな

        // かった。working_content を渡して description / wordCount を編集中の本文から計算する。

        if ( ! empty( $settings['seo_schema'] ) ) {

            $article_schema = self::build_article_schema( $post_id, $result, $get_working_content() );

            $faq_schema     = ( ! empty( $settings['seo_faq'] ) && ! empty( $result['faq'] ) )

                ? self::build_faq_schema( $result['faq'] )

                : null;

            if ( $faq_schema ) {

                // @graph で複数 schema を 1 つの JSON-LD に統合

                // 個別の @context はトップレベルにまとめ、各エントリからは省略する

                unset( $article_schema['@context'] );

                $combined = array(

                    '@context' => 'https://schema.org',

                    '@graph'   => array( $article_schema, $faq_schema ),

                );

                $schema_to_save = $combined;

            } else {

                $schema_to_save = $article_schema;

            }

            // JSON_HEX_TAG: '<' '>' を < > に変換 → </script> 早閉じを防ぐ

            // JSON_HEX_AMP: '&' を & に変換 → HTML エンティティ系の攻撃を防ぐ

            // JSON_HEX_APOS / JSON_HEX_QUOT: 属性内に埋め込んだ場合の早閉じも防ぐ

            update_post_meta( $post_id, '_aiap_schema_json',

                wp_json_encode( $schema_to_save, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT )

            );

        }



        // OGP メタタグ

        // Bug fix: build_ogp_data も同様に編集中の post_content を見るように working_content を渡す。

        if ( ! empty( $settings['seo_ogp'] ) ) {

            $ogp = self::build_ogp_data( $post_id, $result, $get_working_content() );

            update_post_meta( $post_id, '_aiap_ogp_title', sanitize_text_field( $ogp['title'] ) );

            update_post_meta( $post_id, '_aiap_ogp_description', sanitize_text_field( $ogp['description'] ) );

            if ( ! empty( $ogp['image'] ) ) {

                update_post_meta( $post_id, '_aiap_ogp_image', esc_url_raw( $ogp['image'] ) );

            }

        }



        // canonical URL

        if ( ! empty( $settings['seo_canonical'] ) ) {

            update_post_meta( $post_id, '_aiap_canonical_url', esc_url_raw( get_permalink( $post_id ) ) );

        }



        // SEOプラグイン連携の準備フラグ(末尾の wp_update_post 後に実行する)

        $sync_seo_plugins_after_save = ! empty( $settings['seo_plugin_link'] );



        // 内部リンク自動挿入: post_content には書き込まず postmeta フラグだけ立てる。

        // 表示時に the_content フィルタで末尾に「関連記事」ブロックを注入する。

        // (内部リンク機能と seo_related を併用すると2重表示になるため、両方ONなら内部リンク側を優先する)

        $internal_links_enabled = false;

        if ( ! empty( $settings['seo_internal_link'] ) ) {

            update_post_meta( $post_id, '_aiap_show_internal_links', 1 );

            // 関連記事フラグが過去に立っていれば一旦消す(優先順位を反映)

            delete_post_meta( $post_id, '_aiap_show_related' );

            $internal_links_enabled = true;



            // 浮きリンク除去だけは本文に対する直接編集として残す

            // (AI が本文中に紛れ込ませた誘導リンク段落を削除する処理であり、表示時注入とは別目的)

            // Bug fix (audit r9): seo_heading の H1 降格と連鎖するよう $post_changes を経由して読む

            $original = $get_working_content();

            $cleaned  = self::remove_floating_link_paragraphs( $original );

            if ( $cleaned !== $original ) {

                $post_changes['post_content'] = $cleaned;

            }

        }



        // 関連記事セクション(内部リンクがOFFの場合のみ)

        if ( ! empty( $settings['seo_related'] ) && ! $internal_links_enabled ) {

            update_post_meta( $post_id, '_aiap_show_related', 1 );

        }



        // ===== 集約された投稿変更を 1 回だけ wp_update_post でフラッシュ =====

        // post_excerpt と post_content の両方が変わった場合でも save_post フックは 1 回しか走らない。

        if ( ! empty( $post_changes ) ) {

            $post_changes['ID'] = $post_id;

            wp_update_post( $post_changes );

        }



        // SEOプラグイン連携 (audit r4 で wp_update_post の後に移動):

        // 旧版は wp_update_post の前で連携していたため、AIOSEO v4+ の save_post フックで

        // 直後に上書きされる可能性があった。wp_update_post 後に呼ぶことで「最後の書き手」になる。

        if ( $sync_seo_plugins_after_save ) {

            self::sync_seo_plugins( $post_id, $result );

        }



        // サイトマップping (audit r5: IndexNow に新規投稿の URL を通知)

        if ( ! empty( $settings['seo_sitemap_ping'] ) ) {

            self::ping_search_engines( $post_id );

        }



        // llms.txt: ファイルを書き込む方式から動的配信方式に変更したため、

        // 投稿時の生成は不要。設定が ON のとき /llms.txt へのリクエストを捕まえて

        // その場で出力する(maybe_serve_llms_txt 参照)。

        // ここではキャッシュだけクリアして、次回アクセス時に最新の投稿を反映する。

        if ( ! empty( $settings['seo_llms_txt'] ) ) {

            self::clear_llms_txt_cache();

        }

    }



    // ========== 構造化データ ==========



    private static function build_article_schema( $post_id, $result, $working_content = null ) {

        $post   = get_post( $post_id );

        $author = get_userdata( $post->post_author );



        // Bug fix: schema 用の本文ソースを「apply() で編集中の本文 (H1 降格・浮きリンク除去後)」に切替。

        // working_content=null の場合のみ get_post() の本文を使う(後方互換)。

        $effective_content = ( $working_content !== null ) ? (string) $working_content : (string) $post->post_content;



        // Bug fix (audit r5): Google ガイドラインでは headline は 110 文字以内が推奨

        // (NewsArticle では HARD LIMIT、BlogPosting でも超えるとリッチリザルトに

        // 使えない可能性)。長すぎるタイトルを mb_substr で 110 文字以内に切り詰める。

        $headline = (string) $post->post_title;

        if ( AIAP_Compat::mb_strlen( $headline ) > 110 ) {

            $headline = AIAP_Compat::mb_substr( $headline, 0, 110 );

        }



        $description = ! empty( $result['meta_description'] )

            ? (string) $result['meta_description']

            : AIAP_Compat::mb_substr( wp_strip_all_tags( $effective_content ), 0, 155 );



        // Bug fix (audit r5): Google は Article 構造化データで image を必須としている。

        // アイキャッチが未設定でも何らかの image を出すため、以下の順でフォールバックする:

        //   1. アイキャッチ画像 (full size)

        //   2. カスタムロゴ (サイトロゴ)

        //   3. サイトアイコン (favicon/site_icon)

        // すべて取得できない場合のみ image フィールドを省略する。

        $thumb_url = (string) get_the_post_thumbnail_url( $post_id, 'full' );

        $image_url = $thumb_url;

        if ( $image_url === '' ) {

            $custom_logo_id = (int) get_theme_mod( 'custom_logo' );

            if ( $custom_logo_id > 0 ) {

                $logo_src = wp_get_attachment_image_src( $custom_logo_id, 'full' );

                if ( is_array( $logo_src ) && ! empty( $logo_src[0] ) ) {

                    $image_url = (string) $logo_src[0];

                }

            }

        }

        if ( $image_url === '' ) {

            $image_url = (string) get_site_icon_url( 512 );

        }



        // publisher.logo は Google ガイドラインで「600x60 以下、横長推奨」。

        // カスタムロゴが設定されていればそれを使い、無ければサイトアイコンへフォールバック。

        $publisher_logo_url    = '';

        $publisher_logo_width  = 0;

        $publisher_logo_height = 0;

        $custom_logo_id = (int) get_theme_mod( 'custom_logo' );

        if ( $custom_logo_id > 0 ) {

            $logo_src = wp_get_attachment_image_src( $custom_logo_id, 'full' );

            if ( is_array( $logo_src ) ) {

                $publisher_logo_url    = isset( $logo_src[0] ) ? (string) $logo_src[0] : '';

                $publisher_logo_width  = isset( $logo_src[1] ) ? (int) $logo_src[1] : 0;

                $publisher_logo_height = isset( $logo_src[2] ) ? (int) $logo_src[2] : 0;

            }

        }

        if ( $publisher_logo_url === '' ) {

            $publisher_logo_url = (string) get_site_icon_url( 512 );

            // get_site_icon_url 経由ではサイズが取れないので 0 のまま (Google は省略可)

        }



        $publisher_logo = array( '@type' => 'ImageObject', 'url' => $publisher_logo_url );

        if ( $publisher_logo_width > 0 ) {

            $publisher_logo['width']  = $publisher_logo_width;

        }

        if ( $publisher_logo_height > 0 ) {

            $publisher_logo['height'] = $publisher_logo_height;

        }



        $schema = array(

            '@context'         => 'https://schema.org',

            '@type'            => 'BlogPosting',

            'headline'         => $headline,

            'description'      => $description,

            'datePublished'    => get_the_date( 'c', $post_id ),

            'dateModified'     => get_the_modified_date( 'c', $post_id ),

            'url'              => get_permalink( $post_id ),

            'mainEntityOfPage' => array( '@type' => 'WebPage', '@id' => get_permalink( $post_id ) ),

            'author'           => self::build_author_schema( $author ),

            'publisher'        => array(

                '@type' => 'Organization',

                'name'  => get_bloginfo( 'name' ),

                'logo'  => $publisher_logo,

            ),

            // Bug fix (audit r5): Google 推奨の inLanguage を追加。

            // get_locale() は ja_JP 形式、Google は BCP 47 形式 (ja-JP) を推奨。アンダースコアをハイフンへ。

            'inLanguage'       => str_replace( '_', '-', get_locale() ),

        );



        if ( $image_url !== '' ) {

            // Bug fix (audit r5): アイキャッチが取れた場合は ImageObject で width/height も付与。

            // 取れない場合 (=ロゴ等のフォールバック) は url だけ。

            if ( $thumb_url !== '' ) {

                $thumb_id = (int) get_post_thumbnail_id( $post_id );

                $thumb_meta = $thumb_id ? wp_get_attachment_image_src( $thumb_id, 'full' ) : false;

                if ( is_array( $thumb_meta ) ) {

                    $img_obj = array( '@type' => 'ImageObject', 'url' => isset( $thumb_meta[0] ) ? (string) $thumb_meta[0] : $image_url );

                    if ( ! empty( $thumb_meta[1] ) ) $img_obj['width']  = (int) $thumb_meta[1];

                    if ( ! empty( $thumb_meta[2] ) ) $img_obj['height'] = (int) $thumb_meta[2];

                    $schema['image'] = $img_obj;

                } else {

                    $schema['image'] = array( '@type' => 'ImageObject', 'url' => $image_url );

                }

            } else {

                $schema['image'] = array( '@type' => 'ImageObject', 'url' => $image_url );

            }

        }



        if ( ! empty( $result['focus_keyword'] ) ) {

            $schema['keywords'] = $result['focus_keyword'];

        }

        $reading = get_post_meta( $post_id, '_aiap_reading_time', true );

        if ( $reading ) {

            $schema['timeRequired'] = 'PT' . (int) $reading . 'M';

        }



        // Bug fix (audit r5): 任意属性 articleSection (カテゴリ) と wordCount を付与。

        // 必須ではないが Google ガイドラインで「該当する recommended properties は出すべき」

        // とされているため、計算コストの低いものを追加する。

        $cats = get_the_category( $post_id );

        if ( ! empty( $cats ) && is_array( $cats ) ) {

            $cat_names = array();

            foreach ( $cats as $c ) {

                if ( isset( $c->name ) && $c->name !== '' && $c->name !== 'Uncategorized' ) {

                    $cat_names[] = $c->name;

                }

            }

            if ( ! empty( $cat_names ) ) {

                // 1 件なら文字列、複数なら配列で出す (Google はどちらも受理)

                $schema['articleSection'] = ( count( $cat_names ) === 1 ) ? $cat_names[0] : $cat_names;

            }

        }

        $word_count = (int) str_word_count( wp_strip_all_tags( $effective_content ) );

        if ( $word_count === 0 ) {

            // 日本語など str_word_count が機能しない言語向けに、空白以外の文字数で代替

            $word_count = (int) AIAP_Compat::mb_strlen( preg_replace( '/\s+/u', '', wp_strip_all_tags( $effective_content ) ) );

        }

        if ( $word_count > 0 ) {

            $schema['wordCount'] = $word_count;

        }



        return $schema;

    }



    /**

     * 投稿本文中の <h1> 要素と Gutenberg の <!-- wp:heading {"level":1} --> ブロックを

     * <h2> / level:2 に降格する。

     *

     * Bug fix (audit r6): seo_heading 有効時の保険処理。AI への「H1 を使うな」指示は

     * モデル挙動の揺らぎで破られることがあり、投稿タイトル (H1 として表示される) と

     * 本文中の H1 が二重になると SEO 上の混乱を招く。

     *

     * @param string $content 投稿本文 (HTML)

     * @return string 降格後の本文。変更が無ければ入力をそのまま返す。

     */

    private static function demote_h1_headings( $content ) {

        if ( $content === '' || ! is_string( $content ) ) {

            return $content;

        }

        // 早期 return: H1 関連の文字列がそもそも無ければ正規表現を走らせない

        if ( stripos( $content, '<h1' ) === false && strpos( $content, '"level":1' ) === false ) {

            return $content;

        }



        // <h1 ...>...</h1> → <h2 ...>...</h2> (属性もそのまま保持)

        $content = preg_replace( '#<h1(\s[^>]*)?>(.*?)</h1>#is', '<h2$1>$2</h2>', $content );

        // 自己終了形式 (<h1 /> は HTML 仕様上は無効だが念のため)

        $content = preg_replace( '#<h1(\s[^>]*)?/>#i', '<h2$1/>', $content );



        // Gutenberg ブロックコメント側の level も合わせる:

        //   <!-- wp:heading {"level":1} -->  →  <!-- wp:heading {"level":2} -->

        // JSON 風の数値だけを差し替える (他の "level" 表記を誤検出しないよう

        // wp:heading の直後にある属性塊にスコープを限定)

        $content = preg_replace_callback(

            '#(<!--\s*wp:heading\s+)(\{[^}]*\})#',

            function ( $m ) {

                $attrs = $m[2];

                $attrs = preg_replace( '/"level"\s*:\s*1\b/', '"level":2', $attrs );

                return $m[1] . $attrs;

            },

            $content

        );



        return $content;

    }



    /**

     * 著者用 Person 構造化データを組み立てる。Google 推奨の name + url を含める。

     *

     * @param WP_User|false $author 著者オブジェクト

     * @return array

     */

    private static function build_author_schema( $author ) {

        if ( ! $author || ! is_object( $author ) ) {

            return array( '@type' => 'Person', 'name' => 'Unknown' );

        }

        $person = array(

            '@type' => 'Person',

            'name'  => $author->display_name ? (string) $author->display_name : 'Unknown',

        );

        // Bug fix (audit r5): author.url は Google ガイドラインで「強く推奨」。

        // WP 標準のユーザープロフィール URL があればそれを使い、無ければ作者アーカイブにフォールバック。

        if ( ! empty( $author->user_url ) ) {

            $person['url'] = esc_url_raw( $author->user_url );

        } elseif ( ! empty( $author->ID ) ) {

            $author_archive = get_author_posts_url( $author->ID );

            if ( $author_archive ) {

                $person['url'] = esc_url_raw( $author_archive );

            }

        }

        return $person;

    }



    /**

     * FAQPage 構造化データを組み立てる。

     *

     * Bug fix (audit r6): 旧実装では seo_faq を ON にすると AI が本文に Q&A を書き込むだけで、

     * Google が FAQ リッチリザルトを認識するための FAQPage 構造化データは出力されていなかった。

     * AI が JSON フィールド `faq` で返した Q&A 配列を FAQPage / Question / Answer に整形する。

     *

     * 不正な要素 (空文字, 異常型, HTML タグ混入) はその都度スキップする。妥当なエントリが

     * 1 件も無い場合は null を返し、呼び出し側で FAQPage を出力しない判断をさせる。

     *

     * @param array $faq AI が返した [{question, answer}, ...] 形式の配列

     * @return array|null FAQPage schema、または妥当エントリ無しなら null

     */

    private static function build_faq_schema( $faq ) {

        if ( ! is_array( $faq ) || empty( $faq ) ) {

            return null;

        }

        $entries = array();

        foreach ( $faq as $row ) {

            if ( ! is_array( $row ) ) {

                continue;

            }

            $q = isset( $row['question'] ) ? trim( wp_strip_all_tags( (string) $row['question'] ) ) : '';

            $a = isset( $row['answer'] )   ? trim( wp_strip_all_tags( (string) $row['answer'] ) )   : '';

            if ( $q === '' || $a === '' ) {

                continue;

            }

            $entries[] = array(

                '@type' => 'Question',

                'name'  => $q,

                'acceptedAnswer' => array(

                    '@type' => 'Answer',

                    'text'  => $a,

                ),

            );

        }

        if ( empty( $entries ) ) {

            return null;

        }

        return array(

            '@type'      => 'FAQPage',

            'mainEntity' => $entries,

        );

    }



    // ========== OGP ==========



    private static function build_ogp_data( $post_id, $result, $working_content = null ) {

        $post  = get_post( $post_id );

        $thumb = get_the_post_thumbnail_url( $post_id, 'large' );

        // Bug fix: 編集中の post_content を優先(H1 降格・浮きリンク除去後)。

        $effective_content = ( $working_content !== null ) ? (string) $working_content : (string) $post->post_content;

        return array(

            'title'       => $post->post_title,

            'description' => ! empty( $result['meta_description'] ) ? $result['meta_description'] : AIAP_Compat::mb_substr( wp_strip_all_tags( $effective_content ), 0, 155 ),

            'image'       => $thumb ?: '',

        );

    }



    // ========== SEOプラグイン連携 ==========

    //

    // 各 SEO プラグインは postmeta を「公式 API」として扱っている(または下位互換のため

    // 当面サポートしている)。新しい SEO プラグインのバージョンで postmeta 方式が

    // 廃止された場合は、各社が提供するフィルタフックや DB API に切り替える必要がある。

    //

    // Yoast SEO  : `_yoast_wpseo_*` postmeta (Yoast SDK 推奨)

    // Rank Math  : `rank_math_*` postmeta (Rank Math が postmeta API として明示)

    // AIO SEO v3 : `_aioseo_*` postmeta

    // AIO SEO v4+: 内部テーブル `wp_aioseo_posts` (postmeta は WPML 用互換コピーとして

    //              書き込まれるが、TruSEO / フォーカスキーワード / ページ分析は内部

    //              テーブルの値しか見ない)。`AIOSEO\Plugin\Common\Models\Post` モデル経由で書く。



    private static function sync_seo_plugins( $post_id, $result ) {

        $meta_desc = ! empty( $result['meta_description'] ) ? sanitize_text_field( $result['meta_description'] ) : '';

        $focus_kw  = ! empty( $result['focus_keyword'] ) ? sanitize_text_field( $result['focus_keyword'] ) : '';

        $title     = get_the_title( $post_id );



        // Yoast SEO

        if ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Meta' ) ) {

            if ( $meta_desc ) update_post_meta( $post_id, '_yoast_wpseo_metadesc', $meta_desc );

            if ( $focus_kw )  update_post_meta( $post_id, '_yoast_wpseo_focuskw', $focus_kw );

            if ( $title )     update_post_meta( $post_id, '_yoast_wpseo_title', $title );

        }



        // Rank Math

        if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) {

            if ( $meta_desc ) update_post_meta( $post_id, 'rank_math_description', $meta_desc );

            if ( $focus_kw )  update_post_meta( $post_id, 'rank_math_focus_keyword', $focus_kw );

            if ( $title )     update_post_meta( $post_id, 'rank_math_title', $title );

        }



        // All in One SEO

        // Bug fix (audit r4): 旧実装は `_aioseo_*` postmeta だけに書き込んでいたが、

        // AIOSEO v4+ は内部テーブル `wp_aioseo_posts` を一次ソースとしており postmeta は

        // WPML 互換のためのコピーに過ぎない。投稿一覧で SEO スコア / TruSEO バッジが

        // 表示されないなどの症状(特に日本語以外の言語混在環境)を避けるため、Post モデル

        // 経由で内部テーブルへ書き込むパスを優先する。Post モデルが見つからない/失敗した

        // 場合は v3 互換 postmeta にフォールバックする。

        $aioseo_active = defined( 'AIOSEO_VERSION' )

            || class_exists( 'AIOSEO\\Plugin\\AIOSEO' )

            || function_exists( 'aioseo' );

        if ( $aioseo_active ) {

            $handled = self::sync_aioseo_v4( $post_id, $title, $meta_desc, $focus_kw );

            if ( ! $handled ) {

                if ( $meta_desc ) update_post_meta( $post_id, '_aioseo_description', $meta_desc );

                if ( $focus_kw )  update_post_meta( $post_id, '_aioseo_keywords', $focus_kw );

                if ( $title )     update_post_meta( $post_id, '_aioseo_title', $title );

            }

        }

    }



    /**

     * AIOSEO v4+ の `wp_aioseo_posts` テーブルへ Post モデル経由で書き込む。

     *

     * 公式の Post モデル (`AIOSEO\Plugin\Common\Models\Post::getPost($post_id)`) を使い、

     * title / description / keyphrases (JSON) を更新して save() する。

     * モデル/メソッドが利用できない、もしくは保存中に例外が起きた場合は false を返し、

     * 呼び出し側で v3 互換 postmeta にフォールバックさせる。

     *

     * AIOSEO 側のクラス階層は将来変わり得るため、try/catch + class_exists/method_exists

     * + Throwable で防御的に書く。AIOSEO 公式ドキュメントも「object properties は subject

     * to change」と明記しているため、想定外のプロパティ名で失敗してもサイト運用に影響を

     * 出さないことを最優先する。

     *

     * @param int    $post_id   投稿ID

     * @param string $title     SEO タイトル(空文字なら設定しない)

     * @param string $meta_desc メタ説明(空文字なら設定しない)

     * @param string $focus_kw  フォーカスキーワード(空文字なら設定しない)

     * @return bool 成功時 true (= postmeta フォールバック不要)、失敗時 false

     */

    private static function sync_aioseo_v4( $post_id, $title, $meta_desc, $focus_kw ) {

        // Free / Pro どちらかのクラス名を採用する。AIOSEO は Pro 環境でも Common 名前空間の

        // モデルを継承しており、通常 Common\Models\Post で十分。念のため両方を候補にする。

        $candidate_classes = array(

            'AIOSEO\\Plugin\\Common\\Models\\Post',

            'AIOSEO\\Plugin\\Pro\\Models\\Post',

        );

        $model_class = null;

        foreach ( $candidate_classes as $candidate ) {

            if ( class_exists( $candidate ) ) {

                $model_class = $candidate;

                break;

            }

        }

        if ( $model_class === null ) {

            return false;

        }

        if ( ! method_exists( $model_class, 'getPost' ) ) {

            return false;

        }



        try {

            $aioseo_post = call_user_func( array( $model_class, 'getPost' ), (int) $post_id );

            if ( ! is_object( $aioseo_post ) ) {

                return false;

            }



            $changed = false;

            if ( $title !== '' ) {

                $aioseo_post->title = $title;

                $changed = true;

            }

            if ( $meta_desc !== '' ) {

                $aioseo_post->description = $meta_desc;

                $changed = true;

            }

            if ( $focus_kw !== '' ) {

                // keyphrases は AIOSEO 内部で JSON 文字列として保持される構造で、

                // focus(主キーワード) と additional(追加キーワード配列) を持ち、

                // それぞれ keyphrase / score / analysis のサブフィールドを伴う。

                //

                // ここでは score=0 / analysis=空オブジェクトのプレースホルダとして書く。

                // AIOSEO 本体の TruSEO 解析エンジン (Focus Keyphrase Score / Headline Analyzer

                // 系) は JavaScript ベースで実装されており、投稿編集画面が読み込まれたときに

                // のみ走る。PHP 側から呼べる public API は AIOSEO Free / Pro どちらにも公開

                // されていない (公式フォーラムで複数確認、Rank Math も同設計)。そのため、

                // 自動投稿で大量に作成された記事は、ユーザーが対象記事を編集画面で一度開く

                // までは投稿一覧で「N/A」表示のままとなる。これは AIOSEO の設計制約であり、

                // AIAP 側で解消する手段は存在しない。

                //

                // なお Page Analysis スコア (Focus Keyword 非依存。タイトル長 / メタ説明長 /

                // コンテンツ長 / 内部リンク / 画像 alt 等の計測) は PHP で save_post hook 内で

                // AIOSEO が自動計算するため、自動投稿でも数値が表示される。投稿一覧で

                // 「45/100 と N/A が並ぶ」表示はこの非対称性によるもの。

                $kp = array(

                    'focus' => array(

                        'keyphrase' => $focus_kw,

                        'score'     => 0,

                        'analysis'  => new \stdClass(),

                    ),

                    'additional' => array(),

                );

                $aioseo_post->keyphrases = wp_json_encode( $kp );

                $changed = true;

            }



            if ( $changed && method_exists( $aioseo_post, 'save' ) ) {

                $aioseo_post->save();

            }



            return true;

        } catch ( \Throwable $e ) {

            // AIOSEO の内部 API が変わって例外が出ても、サイトの投稿フローは止めない。

            // 開発者向けに WP_DEBUG 環境ではエラーログに残す(ユーザー向け実行ログには出さない)。

            if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {

                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log

                error_log( '[AIAP] AIOSEO sync failed for post ' . (int) $post_id . ': ' . $e->getMessage() );

            }

            return false;

        }

    }



    /**

     * 本文中にAIやプラグインが以前挿入した「浮いたリンク段落」を除去する

     *

     * 対象(本文の途中に挟まっていても削除):

     *  - <p class="aiap-internal-link">...</p>                     (旧ロジックで挿入された段落)

     *  - <p>👉 <a ...>短いテキスト</a></p>                         (AIが装飾として入れた浮きリンク)

     *  - <p><a ...>短い英語タイトル</a></p> のようなリンクだけの段落

     *

     * 通常の本文中リンク(段落内に他の文も含む <a>)は保持する。

     *

     * @param string $content 投稿本文

     * @return string 掃除後の本文

     */

    public static function remove_floating_link_paragraphs( $content ) {

        // 1. 明示クラス付きは最優先で除去

        $content = preg_replace(

            '/<p\s+class=(["\'])[^"\']*\baiap-internal-link\b[^"\']*\1[^>]*>.*?<\/p>\s*/isu',

            '',

            $content

        );



        // 2. 👉(U+1F449) もしくは指さし系絵文字で始まる <p>…<a>…</a>…</p> を除去

        //    ピンポイントに「リンクへの誘導段落」と判断できる形

        $pointer_pattern = '/<p[^>]*>\s*(?:👉|➡️|➜|🔗|&#x1F449;|&#x27A1;)\s*(?:<a\b[^>]*>[^<]{1,80}<\/a>)\s*<\/p>\s*/isu';

        $content = preg_replace( $pointer_pattern, '', $content );



        // 3. リンクだけで構成された短い段落(テキストがリンク以外を含まない)を除去

        //    段落内が「<a>テキスト</a>」のみ(前後の空白や軽微な装飾は許容)の場合に削除

        $content = preg_replace_callback(

            '/<p[^>]*>\s*(?:<(?:strong|em|b|i)>)?\s*<a\b[^>]*>([^<]{1,80})<\/a>\s*(?:<\/(?:strong|em|b|i)>)?\s*<\/p>\s*/isu',

            function ( $m ) {

                // aタグ内テキストが空なら保持(何かの埋め込みかも)

                $link_text = trim( $m[1] );

                if ( $link_text === '' ) return $m[0];

                return '';

            },

            $content

        );



        return $content;

    }



    // ========== サイトマップ通知 ==========

    //

    // Google の sitemap ping エンドポイントは 2023年6月、Bing も同時期に廃止された。

    // これらに対する HTTP リクエストは無効。

    // 代替として IndexNow プロトコル(https://www.indexnow.org/)に対応。

    // ただし IndexNow にはサイト固有のキーが必要なため、

    // wp-content ルートに `aiap-indexnow.txt` というキーファイルがあるサイトのみ通知する。

    private static function ping_search_engines( $post_id = 0 ) {

        // Bug fix (audit r5): 旧実装は home_url('/') (=トップページ) だけを IndexNow に

        // 通知していたが、IndexNow の目的は「更新された URL のクロール促進」であり、

        // トップだけ通知しても新規投稿が早くインデックスされない。新規/更新投稿の

        // パーミリンクを通知する。post_id が 0 の場合のみ後方互換でホームを通知する。

        if ( $post_id > 0 ) {

            $target_url = get_permalink( (int) $post_id );

            // 公開状態でない投稿のパーミリンクは通知しない (404 を IndexNow に伝えても無意味)

            $status = get_post_status( $post_id );

            if ( ! $target_url || $status !== 'publish' ) {

                return;

            }

        } else {

            $target_url = home_url( '/' );

        }



        // wp-content ルートに置かれた aiap-indexnow.txt をキーとして使用(設置はサイト管理者の責任)

        $key_path = WP_CONTENT_DIR . '/aiap-indexnow.txt';

        if ( ! file_exists( $key_path ) || ! is_readable( $key_path ) ) {

            return;

        }

        // WP_Filesystem でキーファイルを読み込む(WordPress.org ガイドライン準拠)

        global $wp_filesystem;

        if ( ! function_exists( 'WP_Filesystem' ) ) {

            require_once ABSPATH . 'wp-admin/includes/file.php';

        }

        if ( ! WP_Filesystem() ) {

            return;

        }

        $key = trim( (string) $wp_filesystem->get_contents( $key_path ) );

        if ( $key === '' || ! preg_match( '/^[a-zA-Z0-9\-]{8,128}$/', $key ) ) {

            return;

        }

        $key_location = content_url( '/aiap-indexnow.txt' );

        // bing.com の IndexNow エンドポイントは Yandex 等にも自動転送される

        $endpoint = 'https://api.indexnow.org/indexnow';

        // Bug fix (横展開 #19): IndexNow 通知も SSRF ガードを適用する。エンドポイントは固定だが、

        // 同種パターンを横展開する CLAUDE.md 方針に従う (`reject_unsafe_urls` + `redirection` 制限)。

        // non-blocking で送るため AIAP_Http ラッパは使わず wp_remote_get に直接渡す。

        wp_remote_get(

            add_query_arg(

                array(

                    'url'         => $target_url,

                    'key'         => $key,

                    'keyLocation' => $key_location,

                ),

                $endpoint

            ),

            array(

                'timeout'            => 5,

                'blocking'           => false,

                'redirection'        => 3,

                'reject_unsafe_urls' => true,

            )

        );

    }



    // ========== llms.txt(動的配信) ==========

    //

    // 設計方針:

    //   WordPress の robots.txt と同じく、ファイルを書き込まずに /llms.txt への

    //   リクエストをフックで受けて動的に出力する。これにより以下を実現:

    //

    //   1. WordPress.org ガイドラインに準拠(プラグインが ABSPATH に書き込まない)

    //   2. 公開記事リストが常に最新(ファイルの再生成タイミングに依存しない)

    //   3. アンインストールでファイルが残らない(=ゴミが出ない)

    //

    // キャッシュは transient で 1時間保持し、save_post で無効化する。



    const LLMS_TXT_CACHE_KEY = 'aiap_llms_txt_cache';

    const LLMS_TXT_CACHE_TTL = HOUR_IN_SECONDS;



    /**

     * /llms.txt へのリクエストを捕まえて、設定が有効なら内容を出力する

     *

     * @param WP $wp WordPress 本体オブジェクト(parse_request フックの引数)

     */

    public static function maybe_serve_llms_txt( $wp ) {

        // リクエスト URL のパスを取得

        if ( empty( $_SERVER['REQUEST_URI'] ) ) {

            return;

        }

        $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );

        $path        = wp_parse_url( $request_uri, PHP_URL_PATH );



        // ホームURLのサブディレクトリ運用にも対応(/blog/llms.txt 等)

        $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );

        $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';

        $expected  = $home_path . '/llms.txt';



        if ( $path !== $expected && $path !== '/llms.txt' ) {

            return;

        }



        // 設定が有効でなければ通常の 404 にフォールバック

        if ( ! class_exists( 'AIAP_AI_Client' ) ) {

            return;

        }

        $settings = AIAP_AI_Client::get_settings();

        if ( empty( $settings['seo_enabled'] ) || empty( $settings['seo_llms_txt'] ) ) {

            return;

        }

        // Bug fix (audit r4): SEO (and therefore llms.txt) is Pro-only. Skip dynamic

        // serving for downgraded sites whose saved seo_llms_txt=1 is stale.

        if ( class_exists( 'AIAP_License' ) && ! AIAP_License::is_pro() ) {

            return;

        }



        // 出力(キャッシュ済みなら再利用)

        $body = self::get_llms_txt_body();



        nocache_headers();

        header( 'Content-Type: text/plain; charset=UTF-8' );

        header( 'X-Robots-Tag: noindex' );

        header( 'Content-Length: ' . strlen( $body ) );



        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- text/plain として出力する llms.txt 本文

        echo $body;

        exit;

    }



    /**

     * llms.txt の本文を生成(キャッシュ対応)

     *

     * @return string text/plain 形式の本文

     */

    private static function get_llms_txt_body() {

        $cached = get_transient( self::LLMS_TXT_CACHE_KEY );

        if ( is_string( $cached ) && $cached !== '' ) {

            return $cached;

        }



        $name = get_bloginfo( 'name' );

        $desc = get_bloginfo( 'description' );

        $url  = home_url( '/' );



        $posts = get_posts( array(

            'posts_per_page' => 10,

            'post_status'    => 'publish',

            'orderby'        => 'date',

            'order'          => 'DESC',

            'no_found_rows'  => true,

        ) );



        $lines = array(

            '# ' . $name,

            '',

            '> ' . $desc,

            '',

            '## About',

            $name . ' — ' . $url,

            '',

            '## Recent Articles',

            '',

        );

        foreach ( $posts as $p ) {

            $lines[] = '- [' . $p->post_title . '](' . get_permalink( $p->ID ) . ')';

        }

        $lines[] = '';



        $body = implode( "\n", $lines );

        set_transient( self::LLMS_TXT_CACHE_KEY, $body, self::LLMS_TXT_CACHE_TTL );



        return $body;

    }



    /**

     * llms.txt キャッシュを破棄(投稿作成・更新時、設定変更時に呼ぶ)

     *

     * @param int|null $post_id (任意)save_post から呼ばれるときの投稿ID

     */

    public static function clear_llms_txt_cache( $post_id = null ) {

        delete_transient( self::LLMS_TXT_CACHE_KEY );

    }



    /**

     * 旧バージョンが ABSPATH に書いた llms.txt があれば削除する

     *

     * 1.0.0 リリース前の内部開発版では物理ファイルを書き込んでいた経緯がある。

     * 動的配信方式へ移行したため、残骸を放置すると Apache/Nginx が静的ファイル

     * として優先配信してしまい、WordPress 側のフックが効かなくなる。

     * よって管理画面アクセス時に1回だけクリーンアップする。

     */

    public static function cleanup_legacy_llms_txt() {

        // 1度だけ実行されるようフラグで管理

        if ( get_option( 'aiap_llms_txt_legacy_cleaned' ) ) {

            return;

        }



        // WP_Filesystem 経由でファイル操作 (WordPress.org ガイドライン準拠)。

        // 直接の file_exists / is_writable / file_get_contents は WP.org Plugin Check で

        // ERROR 扱いとなる。

        global $wp_filesystem;

        if ( ! function_exists( 'WP_Filesystem' ) ) {

            require_once ABSPATH . 'wp-admin/includes/file.php';

        }

        if ( ! WP_Filesystem() ) {

            // ファイルシステム初期化に失敗した場合は今回はスキップ。

            // フラグも立てず、次回管理画面アクセス時に再試行させる。

            return;

        }



        $legacy_path = ABSPATH . 'llms.txt';

        if ( $wp_filesystem->exists( $legacy_path ) && $wp_filesystem->is_writable( $legacy_path ) ) {

            // 自プラグインが書いたものか簡易判定: サイト名が先頭にある形式

            // (誤って他のツールが置いた llms.txt を消さないよう保守的にチェック)

            $contents = $wp_filesystem->get_contents( $legacy_path );

            $head     = is_string( $contents ) ? substr( $contents, 0, 256 ) : '';

            $name     = get_bloginfo( 'name' );

            if ( $head !== '' && strpos( $head, '# ' . $name ) === 0 ) {

                wp_delete_file( $legacy_path );

            }

        }



        update_option( 'aiap_llms_txt_legacy_cleaned', 1, false );

    }



    // ========== フロントエンド出力 ==========



    /**

     * SEO 機能群がランタイムで有効かを判定する単一窓口。

     *

     * Bug fix (audit r4): 旧実装は seo_enabled だけを見ていたため、Pro→Free に

     * ダウングレードした後も保存値 seo_enabled=1 のままで JSON-LD / OGP / canonical /

     * 関連記事注入が出続けていた。AIAP_SEO::apply() の Pro ガードと整合させる。

     *

     * @param array $settings 任意。省略時は AIAP_AI_Client::get_settings() を使う。

     * @return bool

     */

    private static function seo_active( $settings = null ) {

        if ( $settings === null ) {

            $settings = AIAP_AI_Client::get_settings();

        }

        if ( empty( $settings['seo_enabled'] ) ) {

            return false;

        }

        if ( class_exists( 'AIAP_License' ) && ! AIAP_License::is_pro() ) {

            return false;

        }

        return true;

    }



    /**

     * 外部 SEO プラグイン (Yoast / Rank Math / AIOSEO) がフロントエンドの

     * canonical / OGP / Schema 出力を担うべき状態か。

     *

     * Bug fix (audit r6): 旧実装は AIAP の output_schema/output_ogp/output_canonical を

     * 無条件出力していた。Yoast 等は独自フックで同じタグを出すため、ユーザーが

     * `seo_plugin_link` を ON にして「プラグイン側でも管理する」と表明している場合、

     * AIAP の独自出力は重複の原因になる。`seo_plugin_link` ON + 既知 SEO プラグイン

     * 検出時は AIAP の独自出力をスキップし、postmeta 連携のみ行う運用にする。

     *

     * @param array $settings 任意。省略時は AIAP_AI_Client::get_settings() を使う。

     * @return bool true なら AIAP は独自フロントエンド出力をスキップすべき

     */

    private static function seo_plugin_owns_frontend( $settings = null ) {

        if ( $settings === null ) {

            $settings = AIAP_AI_Client::get_settings();

        }

        if ( empty( $settings['seo_plugin_link'] ) ) {

            return false;

        }

        // Yoast SEO

        if ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Meta' ) ) {

            return true;

        }

        // Rank Math

        if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) ) {

            return true;

        }

        // All in One SEO (Free / Pro どちらでも検出)

        if ( defined( 'AIOSEO_VERSION' ) || class_exists( 'AIOSEO\\Plugin\\AIOSEO' ) || function_exists( 'aioseo' ) ) {

            return true;

        }

        return false;

    }



    public static function output_schema() {

        if ( ! is_singular( 'post' ) ) return;

        $settings = AIAP_AI_Client::get_settings();

        if ( ! self::seo_active( $settings ) || empty( $settings['seo_schema'] ) ) return;

        // Bug fix (audit r6): SEOプラグインが居る + 連携 ON のときは Schema 出力を任せる

        if ( self::seo_plugin_owns_frontend( $settings ) ) return;

        $schema = get_post_meta( get_the_ID(), '_aiap_schema_json', true );

        if ( $schema ) {

            // 新規保存は JSON_HEX_TAG | JSON_HEX_AMP 経由で安全化済み(apply 内)。

            // 旧バージョンで保存された schema 用に str_replace による保険的エスケープも残す。

            $safe = str_replace(

                array( '</', '<!--', '-->' ),

                array( '<\\/', '<\\!--', '--\\>' ),

                $schema

            );

            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-LD schema from wp_json_encode; script injection prevented by JSON_HEX_TAG flag and str_replace defense-in-depth

            echo '<script type="application/ld+json">' . $safe . '</script>' . "\n";

        }

    }



    public static function output_ogp() {

        if ( ! is_singular( 'post' ) ) return;

        $settings = AIAP_AI_Client::get_settings();

        if ( ! self::seo_active( $settings ) || empty( $settings['seo_ogp'] ) ) return;

        // Bug fix (audit r6): SEOプラグインが居る + 連携 ON のときは OGP 出力を任せる

        if ( self::seo_plugin_owns_frontend( $settings ) ) return;

        $id    = get_the_ID();

        $title = get_post_meta( $id, '_aiap_ogp_title', true );

        $desc  = get_post_meta( $id, '_aiap_ogp_description', true );

        $image = get_post_meta( $id, '_aiap_ogp_image', true );

        if ( ! $title && ! $desc ) return;



        // Bug fix (audit r5): canonical を出している場合は og:url もそれと一致させる方が

        // OGP/SEO 両面で整合する (シェア時に正規 URL が使われる)。canonical が無ければ

        // permalink を使う。

        $canonical = get_post_meta( $id, '_aiap_canonical_url', true );

        $og_url    = $canonical ? $canonical : get_permalink( $id );



        if ( $title ) echo '<meta property="og:title" content="' . esc_attr( $title ) . '" />' . "\n";

        if ( $desc )  echo '<meta property="og:description" content="' . esc_attr( $desc ) . '" />' . "\n";



        // Bug fix (audit r5): og:image だけでなく width/height/alt も出す。

        // 画像クロー ラがプレビュー生成時に正しいサイズで表示できる + アクセシビリティ向上。

        if ( $image ) {

            echo '<meta property="og:image" content="' . esc_url( $image ) . '" />' . "\n";



            $thumb_id = (int) get_post_thumbnail_id( $id );

            if ( $thumb_id > 0 ) {

                $img_meta = wp_get_attachment_image_src( $thumb_id, 'large' );

                if ( is_array( $img_meta ) ) {

                    if ( ! empty( $img_meta[1] ) ) {

                        echo '<meta property="og:image:width" content="' . esc_attr( (int) $img_meta[1] ) . '" />' . "\n";

                    }

                    if ( ! empty( $img_meta[2] ) ) {

                        echo '<meta property="og:image:height" content="' . esc_attr( (int) $img_meta[2] ) . '" />' . "\n";

                    }

                }

                $alt = (string) get_post_meta( $thumb_id, '_wp_attachment_image_alt', true );

                if ( $alt !== '' ) {

                    echo '<meta property="og:image:alt" content="' . esc_attr( $alt ) . '" />' . "\n";

                }

            }

        }



        echo '<meta property="og:type" content="article" />' . "\n";

        echo '<meta property="og:url" content="' . esc_url( $og_url ) . '" />' . "\n";

        echo '<meta property="og:site_name" content="' . esc_attr( get_bloginfo( 'name' ) ) . '" />' . "\n";



        // Bug fix (audit r5): og:locale は OGP プロトコル規約で ja_JP / en_US などの

        // アンダースコア形式 (BCP 47 ではない)。get_locale() がそのまま使える。

        $locale = (string) get_locale();

        if ( $locale !== '' ) {

            echo '<meta property="og:locale" content="' . esc_attr( $locale ) . '" />' . "\n";

        }



        // Bug fix (audit r5): og:type=article のとき推奨される article:* メタを追加。

        // Facebook / X / LinkedIn のクローラがプレビューやサマリに使う。

        $published_time = get_the_date( 'c', $id );

        $modified_time  = get_the_modified_date( 'c', $id );

        if ( $published_time ) {

            echo '<meta property="article:published_time" content="' . esc_attr( $published_time ) . '" />' . "\n";

        }

        if ( $modified_time ) {

            echo '<meta property="article:modified_time" content="' . esc_attr( $modified_time ) . '" />' . "\n";

        }

        $author_id = (int) get_post_field( 'post_author', $id );

        if ( $author_id > 0 ) {

            $author_url = get_author_posts_url( $author_id );

            if ( $author_url ) {

                echo '<meta property="article:author" content="' . esc_url( $author_url ) . '" />' . "\n";

            }

        }

        $cats = get_the_category( $id );

        if ( ! empty( $cats ) && is_array( $cats ) ) {

            // 主要カテゴリを 1 件だけ article:section として出す (OGP 規約)

            $primary_cat = $cats[0];

            if ( ! empty( $primary_cat->name ) && $primary_cat->name !== 'Uncategorized' ) {

                echo '<meta property="article:section" content="' . esc_attr( $primary_cat->name ) . '" />' . "\n";

            }

        }

        $tags = get_the_tags( $id );

        if ( ! empty( $tags ) && is_array( $tags ) ) {

            // タグは複数 article:tag として出力可

            foreach ( $tags as $tag ) {

                if ( ! empty( $tag->name ) ) {

                    echo '<meta property="article:tag" content="' . esc_attr( $tag->name ) . '" />' . "\n";

                }

            }

        }

    }



    public static function output_canonical() {

        if ( ! is_singular( 'post' ) ) return;

        $settings = AIAP_AI_Client::get_settings();

        if ( ! self::seo_active( $settings ) || empty( $settings['seo_canonical'] ) ) return;

        // Bug fix (audit r6): SEOプラグインが居る + 連携 ON のときは canonical 出力を任せる

        // (Yoast/Rank Math/AIOSEO は独自に canonical を出すため、AIAP も出すと重複する)

        if ( self::seo_plugin_owns_frontend( $settings ) ) return;

        $canonical = get_post_meta( get_the_ID(), '_aiap_canonical_url', true );

        if ( ! $canonical ) return;

        remove_action( 'wp_head', 'rel_canonical' );

        echo '<link rel="canonical" href="' . esc_url( $canonical ) . '" />' . "\n";

    }



    // ========== 表示時注入 (the_content フィルタ) ==========



    /**

     * 投稿表示時に関連記事/内部リンクを末尾に注入する

     *

     * post_content への書き込みは行わない。postmeta のフラグを見て

     * 該当ブロックの HTML を返す。Gutenberg ブロック構造には影響しない。

     *

     * @param string $content 投稿本文

     * @return string 注入後の本文

     */

    public static function filter_the_content( $content ) {

        // メインクエリのシングル投稿表示時のみ

        if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {

            return $content;

        }



        $post_id = get_the_ID();

        if ( ! $post_id ) {

            return $content;

        }



        // Bug fix (audit r4): 関連記事 / 内部リンク注入は SEO Pro 機能の一部。

        // ダウングレード後にも postmeta フラグが残った投稿で出続けないよう、ランタイムでも

        // Pro ガードする(他のフロントエンド出力と整合)。

        if ( ! self::seo_active() ) {

            return $content;

        }



        // 内部リンク優先(seo_related と併用時のロジックは apply() で確定済み)

        if ( get_post_meta( $post_id, '_aiap_show_internal_links', true ) ) {

            $append = self::render_internal_links_block( $post_id );

            if ( $append !== '' ) {

                return $content . $append;

            }

        } elseif ( get_post_meta( $post_id, '_aiap_show_related', true ) ) {

            $append = self::render_related_posts_block( $post_id );

            if ( $append !== '' ) {

                return $content . $append;

            }

        }

        return $content;

    }



    /**

     * 内部リンクブロックの HTML を生成

     *

     * カテゴリ内のランダム投稿を最大3件選んで <aside> ブロックを構築する。

     * 関連投稿が0件、またはカテゴリが無い場合は空文字を返す。

     *

     * @param int $post_id 投稿ID

     * @return string HTML(空なら '')

     */

    public static function render_internal_links_block( $post_id ) {

        $cats = wp_get_post_categories( $post_id );

        if ( empty( $cats ) ) {

            return '';

        }



        $related = get_posts( array(

            'category__in'   => $cats,

            'post__not_in'   => array( $post_id ),

            'posts_per_page' => 3,

            'post_status'    => 'publish',

            'orderby'        => 'rand',

        ) );



        if ( empty( $related ) ) {

            return '';

        }



        $items = array();

        foreach ( $related as $rp ) {

            $items[] = sprintf(

                '<li><a href="%s">%s</a></li>',

                esc_url( get_permalink( $rp->ID ) ),

                esc_html( $rp->post_title )

            );

        }

        $heading_label = __( 'Related posts', 'ai-auto-poster' );

        return "\n<aside class=\"aiap-internal-links\" aria-label=\"" . esc_attr( $heading_label ) . "\">\n"

             . '  <h3 class="aiap-internal-links-heading">' . esc_html( $heading_label ) . "</h3>\n"

             . "  <ul class=\"aiap-internal-links-list\">\n    " . implode( "\n    ", $items ) . "\n  </ul>\n"

             . "</aside>\n";

    }



    /**

     * 関連記事ブロックの HTML を生成

     *

     * カテゴリ内の最新投稿を最大5件選んで <div class="aiap-related-posts"> を構築する。

     * 関連投稿が0件、またはカテゴリが無い場合は空文字を返す。

     *

     * @param int $post_id 投稿ID

     * @return string HTML(空なら '')

     */

    public static function render_related_posts_block( $post_id ) {

        $cats = wp_get_post_categories( $post_id );

        if ( empty( $cats ) ) {

            return '';

        }



        $related = get_posts( array(

            'category__in'   => $cats,

            'post__not_in'   => array( $post_id ),

            'posts_per_page' => 5,

            'post_status'    => 'publish',

            'orderby'        => 'date',

            'order'          => 'DESC',

        ) );

        if ( empty( $related ) ) {

            return '';

        }



        $heading = __( 'Related posts', 'ai-auto-poster' );

        $html = "\n\n" . '<div class="aiap-related-posts">' . "\n<h2 class=\"aiap-related-posts-heading\">" . esc_html( $heading ) . "</h2>\n<ul class=\"aiap-related-posts-list\">\n";

        foreach ( $related as $rp ) {

            $html .= sprintf( "<li><a href=\"%s\">%s</a></li>\n", esc_url( get_permalink( $rp->ID ) ), esc_html( $rp->post_title ) );

        }

        $html .= "</ul>\n</div>";

        return $html;

    }

}

