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

/**
 * SEO最適化処理クラス
 *
 * AI生成結果を受けて、投稿にSEO関連のメタデータ・構造化データ等を適用する。
 * 各機能は設定で個別にON/OFF可能。
 */
class AIAP_SEO {

    /**
     * SEO設定のデフォルト値
     */
    public static function get_defaults() {
        return array(
            'seo_enabled'       => 0,
            'seo_meta_desc'     => 1,
            'seo_heading'       => 1,
            'seo_focus_kw'      => 1,
            'seo_faq'           => 0,
            'seo_toc'           => 0,
            'seo_template'      => 0,
            'seo_img_alt'       => 1,
            'seo_img_webp'      => 0,
            'seo_internal_link' => 1,
            'seo_auto_tag'      => 1,
            'seo_related'       => 0,
            'seo_schema'        => 1,
            'seo_ogp'           => 0,
            'seo_canonical'     => 0,
            'seo_plugin_link'   => 0,
            'seo_reading_time'  => 0,
            'seo_sitemap_ping'  => 0,
            'seo_llms_txt'      => 0,
        );
    }

    /**
     * フロントエンド出力フックを登録
     */
    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            = 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            = 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 が両方のリッチリザルト要件を判定できる。
        if ( ! empty( $settings['seo_schema'] ) ) {
            $article_schema = self::build_article_schema( $post_id, $result );
            $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 メタタグ
        if ( ! empty( $settings['seo_ogp'] ) ) {
            $ogp = self::build_ogp_data( $post_id, $result );
            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 ) {
        $post   = get_post( $post_id );
        $author = get_userdata( $post->post_author );

        // Bug fix (audit r5): Google ガイドラインでは headline は 110 文字以内が推奨
        // (NewsArticle では HARD LIMIT、BlogPosting でも超えるとリッチリザルトに
        // 使えない可能性)。長すぎるタイトルを mb_substr で 110 文字以内に切り詰める。
        $headline = (string) $post->post_title;
        if ( mb_strlen( $headline ) > 110 ) {
            $headline = mb_substr( $headline, 0, 110 );
        }

        $description = ! empty( $result['meta_description'] )
            ? (string) $result['meta_description']
            : mb_substr( wp_strip_all_tags( $post->post_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( (string) $post->post_content ) );
        if ( $word_count === 0 ) {
            // 日本語など str_word_count が機能しない言語向けに、空白以外の文字数で代替
            $word_count = (int) mb_strlen( preg_replace( '/\s+/u', '', wp_strip_all_tags( (string) $post->post_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 ) {
        $post  = get_post( $post_id );
        $thumb = get_the_post_thumbnail_url( $post_id, 'large' );
        return array(
            'title'       => $post->post_title,
            'description' => ! empty( $result['meta_description'] ) ? $result['meta_description'] : mb_substr( wp_strip_all_tags( $post->post_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';
        wp_remote_get(
            add_query_arg(
                array(
                    'url'         => $target_url,
                    'key'         => $key,
                    'keyLocation' => $key_location,
                ),
                $endpoint
            ),
            array( 'timeout' => 5, 'blocking' => false )
        );
    }

    // ========== 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;
    }
}
