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

/**
 * 管理画面のフォーム保存コントローラ。
 *
 * 設定・プロバイダ・画像・システムプロンプト・タスクの各保存フォーム
 * (admin_post_aiap_* アクション) を処理するハンドラ群を集約する。描画ロジック
 * (render_*) からリクエスト処理を分離するための入れ物 (Phase 3 ステップ3)。
 *
 * 各ハンドラは外部ドメインクラス (AIAP_AI_Client / AIAP_Image_Client /
 * AIAP_Prompts 等) に委譲し、共有グルーである権限/nonce 検証とリダイレクトは
 * AIAP_Admin::verify() / AIAP_Admin::redirect() を明示的に呼ぶ。
 *
 * フック登録 (admin_post_aiap_*) は AIAP_Admin::init() が行う。
 */
class AIAP_Admin_Controller {

    private static function merge_posted_api_keys( array $settings, array $fields, &$has_new_api_key ) {
        $has_new_api_key = false;
        foreach ( $fields as $field ) {
            if ( ! isset( $_POST[ $field ] ) ) {
                continue;
            }
            $value = trim( (string) wp_unslash( $_POST[ $field ] ) );
            if ( $value === '' ) {
                continue;
            }
            $has_new_api_key = true;
            $settings[ $field ] = $value;
        }
        return $settings;
    }

    private static function read_task_form() {
        $prompt        = sanitize_textarea_field( wp_unslash( $_POST['prompt'] ?? '' ) );
        $language      = sanitize_text_field( wp_unslash( $_POST['task_content_language'] ?? '' ) );
        $schedule_raw  = isset( $_POST['schedule'] ) ? wp_unslash( $_POST['schedule'] ) : array();
        $length        = isset( $_POST['task_content_length'] ) && wp_unslash( $_POST['task_content_length'] ) !== ''
            ? max( 0, min( 10000, absint( wp_unslash( $_POST['task_content_length'] ) ) ) )
            : 0;
        $image_mode    = AIAP_Prompts::sanitize_image_mode( wp_unslash( $_POST['task_featured_image_mode'] ?? 'on' ) );
        // Bug fix: 配列要素ごとに sanitize_key を適用 (admin-runner と同じポリシー)。
        $rss_feed_ids_raw = isset( $_POST['rss_feed_ids'] ) ? (array) wp_unslash( $_POST['rss_feed_ids'] ) : array();
        $rss_feed_ids     = array_values( array_filter( array_map( 'sanitize_key', $rss_feed_ids_raw ), 'strlen' ) );
        $rss_input     = array(
            'rss_enabled'  => isset( $_POST['rss_enabled'] )  ? $_POST['rss_enabled']  : 0,
            'rss_feed_ids' => $rss_feed_ids,
            'rss_count'    => isset( $_POST['rss_count'] )    ? $_POST['rss_count']    : 5,
            'rss_mode'     => isset( $_POST['rss_mode'] )     ? sanitize_text_field( wp_unslash( $_POST['rss_mode'] ) ) : 'pick',
            'rss_fallback' => isset( $_POST['rss_fallback'] ) ? sanitize_text_field( wp_unslash( $_POST['rss_fallback'] ) ) : 'skip',
        );
        $rss_normalized = AIAP_Prompts::get_rss_settings( $rss_input );
        $random_mode      = AIAP_Prompts::sanitize_random_mode( $_POST['task_random_mode'] ?? 0 );
        $random_count_min = AIAP_Prompts::sanitize_random_count( $_POST['task_random_count_min'] ?? 0 );
        $random_count_max = AIAP_Prompts::sanitize_random_count( $_POST['task_random_count_max'] ?? 0 );
        if ( $random_count_max < $random_count_min ) {
            $random_count_max = $random_count_min;
        }

        return array(
            'invalid_schedule' => AIAP_Prompts::count_invalid_in_schedule_form( $schedule_raw ),
            'data'             => array(
                'name'                 => sanitize_text_field( wp_unslash( $_POST['task_name'] ?? '' ) ),
                'prompt'               => $prompt,
                'schedule'             => AIAP_Prompts::parse_schedule_from_form( $schedule_raw ),
                'enabled'              => isset( $_POST['task_enabled'] ) ? 1 : 0,
                'content_language'     => $language,
                'content_length'       => $length,
                'featured_image_mode'  => $image_mode,
                'random_mode'          => $random_mode,
                'random_count_min'     => $random_count_min,
                'random_count_max'     => $random_count_max,
                'allowed_periods'      => AIAP_Prompts::sanitize_allowed_periods( $_POST['task_allowed_periods'] ?? array() ),
                'min_interval_minutes' => AIAP_Prompts::sanitize_min_interval_minutes( $_POST['task_min_interval_minutes'] ?? 0 ),
                'rss_enabled'          => $rss_normalized['rss_enabled'],
                'rss_feed_ids'         => $rss_normalized['rss_feed_ids'],
                'rss_count'            => $rss_normalized['rss_count'],
                'rss_mode'             => $rss_normalized['rss_mode'],
                'rss_fallback'         => $rss_normalized['rss_fallback'],
            ),
        );
    }

    private static function extract_task_rss_data( array $task_data ) {
        return array(
            'rss_enabled'  => $task_data['rss_enabled'],
            'rss_feed_ids' => $task_data['rss_feed_ids'],
            'rss_count'    => $task_data['rss_count'],
            'rss_mode'     => $task_data['rss_mode'],
            'rss_fallback' => $task_data['rss_fallback'],
        );
    }

    /**
     * 「投稿し損ねたとき」セクションの保存。
     * cron_recover_missed / cron_recover_max のみ。
     * cron スケジュール変更を伴うので reschedule() を呼ぶ。
     */
    public static function handle_save_recover() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        $is_pro = AIAP_License::is_pro();
        // Bug fix: OFF→ON 遷移時に OPTION_RECOVER_ENABLED_FROM に切替時刻を記録し、
        // それより前の過去日を取りこぼし対象から除外する。
        $prev_enabled = ! empty( $s['cron_recover_missed'] );
        $new_enabled  = isset( $_POST['cron_recover_missed'] );
        $s['cron_recover_missed'] = $new_enabled ? 1 : 0;
        if ( $is_pro ) {
            $s['cron_recover_max'] = max( 1, min( 5, absint( wp_unslash( $_POST['cron_recover_max'] ?? 3 ) ) ) );
        } else {
            // Free プラン: 1 件固定 (UI の disabled を bypass されても強制)
            $s['cron_recover_max'] = 1;
        }
        AIAP_AI_Client::update_settings( $s );
        if ( class_exists( 'AIAP_Scheduler' ) ) {
            AIAP_Scheduler::note_recover_transition( $prev_enabled, $new_enabled );
        }
        AIAP_Scheduler::reschedule();
        AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    /**
     * 「投稿の出力」セクションの保存。
     * post_status / post_category / post_author のみ。
     */
    public static function handle_save_output() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        $posted_status        = sanitize_text_field( wp_unslash( $_POST['post_status'] ?? '' ) );
        $s['post_status']     = in_array( $posted_status, array('draft','pending','publish'), true ) ? $posted_status : 'draft';
        $s['post_category']   = absint( wp_unslash( $_POST['post_category'] ?? 0 ) );
        // Bug fix (audit r9 self-audit): post_author は absint だけだと存在しないユーザー ID を
        // 受け入れてしまう。設定後に管理者を削除した・user_id を手で書き換えた等のケースで、
        // wp_insert_post( ['post_author' => 999] ) が post_author を NULL のまま保存し、
        // フロントエンドで「by 」と作者欄が空になる事故が起きる。get_userdata でユーザーが
        // 存在することを確認し、存在しない場合は既存値(あれば)を維持、無ければ現在の管理者に
        // フォールバックする。
        $posted_author = absint( wp_unslash( $_POST['post_author'] ?? 1 ) );
        if ( $posted_author > 0 && get_userdata( $posted_author ) !== false ) {
            $s['post_author'] = $posted_author;
        } else {
            // 既存値が有効ならそれを維持。なければ現在の管理者にフォールバック。
            $existing_author = isset( $s['post_author'] ) ? (int) $s['post_author'] : 0;
            if ( $existing_author <= 0 || get_userdata( $existing_author ) === false ) {
                $s['post_author'] = (int) get_current_user_id();
            }
        }
        AIAP_AI_Client::update_settings( $s );
        AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }



    /**
     * 「天気情報」セクションの保存。
     * APIキー不要の Open-Meteo JMA API 用に、地域名・緯度経度・キャッシュ時間を保存する。
     */
    public static function handle_save_weather() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        if ( class_exists( 'AIAP_Weather_Client' ) ) {
            $s = AIAP_Weather_Client::sanitize_settings( $s );
            $validation = AIAP_Weather_Client::validate_settings( $s );
            if ( is_wp_error( $validation ) ) {
                $s['weather_enabled'] = 0;
                AIAP_AI_Client::update_settings( $s );
                AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, $validation->get_error_message(), 'error' );
            }
        }
        AIAP_AI_Client::update_settings( $s );
        AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    /**
     * 「タイトルで類似投稿を判定する」セクションの保存。無償・全プラン対象。
     *
     * 直近 20 件の AI 生成タイトルを AI プロンプトに含めて、似た記事を書かない
     * よう事前指示する機能の ON/OFF 切替。Pro 限定の dedup (生成後の類似度
     * チェック+再生成) とは別レイヤー。
     */
    public static function handle_save_avoid_dup() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        $s['avoid_duplicate_topics'] = isset( $_POST['avoid_duplicate_topics'] ) ? 1 : 0;
        AIAP_AI_Client::update_settings( $s );
        AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    /**
     * 「重複記事を防ぐ」セクションの保存。Pro のみフォーム反映。
     */
    public static function handle_save_dedup() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        $is_pro = AIAP_License::is_pro();
        // Free は既存値を保持 (UI 非活性 + サーバ側でも反映しない)
        if ( $is_pro ) {
            $s['dedup_enabled']   = isset( $_POST['dedup_enabled'] ) ? 1 : 0;
            $s['dedup_step1']     = isset( $_POST['dedup_step1'] ) ? 1 : 0;
            $s['dedup_step2']     = isset( $_POST['dedup_step2'] ) ? 1 : 0;
            $s['dedup_max_retry'] = max( 1, min( 10, absint( wp_unslash( $_POST['dedup_max_retry'] ?? 3 ) ) ) );
            $sens                 = sanitize_text_field( wp_unslash( $_POST['dedup_sensitivity'] ?? 'medium' ) );
            $s['dedup_sensitivity'] = in_array( $sens, array( 'low', 'medium', 'high' ), true ) ? $sens : 'medium';
        }
        AIAP_AI_Client::update_settings( $s );
        AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    /**
     * 「SEO最適化」セクションの保存。Pro のみフォーム反映。
     */
    public static function handle_save_seo() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        $is_pro = AIAP_License::is_pro();
        $seo_fields = array('seo_enabled','seo_meta_desc','seo_heading','seo_focus_kw','seo_faq','seo_toc','seo_template','seo_img_alt','seo_img_webp','seo_internal_link','seo_auto_tag','seo_related','seo_schema','seo_ogp','seo_canonical','seo_plugin_link','seo_reading_time','seo_sitemap_ping','seo_llms_txt');
        // Free は既存値を保持 (UI 非活性 + サーバ側でも反映しない)
        if ( $is_pro ) {
            foreach ( $seo_fields as $f ) $s[ $f ] = isset( $_POST[ $f ] ) ? 1 : 0;
        }
        AIAP_AI_Client::update_settings( $s );
        AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    public static function handle_save_provider() {
        AIAP_Admin::verify();
        $s   = AIAP_AI_Client::get_settings();
        $tab = sanitize_key( wp_unslash( $_POST['provider_tab'] ?? ( $s['provider'] ?? 'claude' ) ) );
        if ( ! in_array( $tab, AIAP_AI_Providers::get_providers(), true ) ) $tab = 'claude';

        // APIキー: 空送信は「未変更」扱いで既存値を保持。新方式 (value="" + AJAX reveal) では
        // ユーザーが何も触らなければ空が送られるため、$val === '' チェックで十分。
        $s = self::merge_posted_api_keys( $s, AIAP_AI_Providers::get_key_fields(), $has_new_api_key );
        if ( $has_new_api_key ) {
            $crypto_ok = AIAP_Crypto::ensure_available();
            if ( is_wp_error( $crypto_ok ) ) {
                AIAP_Admin::redirect( AIAP_Tabs::PROVIDER . '&sub=' . $tab, $crypto_ok->get_error_message(), 'error' );
                return;
            }
        }

        // 「使用するプロバイダ」の切替
        if ( ! empty( $_POST['provider'] ) ) {
            $posted_provider = sanitize_key( wp_unslash( $_POST['provider'] ) );
            if ( in_array( $posted_provider, AIAP_AI_Providers::get_providers(), true ) ) {
                $s['provider'] = $posted_provider;
            }
        }
        foreach ( array('claude_model','openai_model','gemini_model','mistral_model','groq_model','deepseek_model','openrouter_model') as $f ) {
            if ( isset( $_POST[ $f ] ) ) $s[ $f ] = sanitize_text_field( wp_unslash( $_POST[ $f ] ) );
        }
        foreach ( AIAP_AI_Providers::get_providers() as $provider ) {
            $custom = sanitize_text_field( wp_unslash( $_POST[ $provider . '_custom_model' ] ?? '' ) );
            // カスタムモデルは英数字・ハイフン・スラッシュ・コロン・ピリオドのみ、最大128文字
            if ( $custom !== '' && preg_match( '#^[A-Za-z0-9._/:\-]{1,128}$#', $custom ) && isset( $s[ $provider . '_model' ] ) ) {
                $s[ $provider . '_model' ] = $custom;
            }
        }

        // Bug fix: discontinued モデルを保存しようとした場合、UI メッセージ
        // "It will be switched on save." の約束通り Registry default に自動切替する。
        // 旧実装は POST 値をそのまま保存していたため、ユーザーがドロップダウンを触らず
        // Save しても discontinued モデルが残り続け、警告と保存値が乖離していた。
        // 判定基準: get_models_for() の返す「動的取得済 transient or Registry 既定」リストに
        // 含まれない、かつカスタムモデルでもない場合のみ切替対象とする。
        // 切替先優先順位: (1) Registry default が動的取得リストに含まれていればそれ、
        //                 (2) 含まれていなければ array_key_first (AJAX 経路と同じフォールバック)。
        if ( class_exists( 'AIAP_Model_Registry' ) ) {
            foreach ( AIAP_AI_Providers::get_providers() as $provider ) {
                $field = $provider . '_model';
                $value = isset( $s[ $field ] ) ? (string) $s[ $field ] : '';
                if ( $value === '' ) {
                    continue;
                }
                // カスタムモデル POST があった場合はユーザー意図優先で切替しない
                $custom = sanitize_text_field( wp_unslash( $_POST[ $provider . '_custom_model' ] ?? '' ) );
                if ( $custom !== '' ) {
                    continue;
                }
                $available = AIAP_AI_Providers::get_models_for( $provider );
                if ( is_array( $available ) && ! empty( $available ) && ! isset( $available[ $value ] ) ) {
                    $default_model = AIAP_Model_Registry::default_text_model( $provider );
                    if ( $default_model !== '' && isset( $available[ $default_model ] ) ) {
                        $s[ $field ] = $default_model;
                    } else {
                        // Registry default も動的取得リストに無い場合は先頭フォールバック
                        // (AJAX Refresh 経路の挙動と対称)。これにより UI 表記詐欺残存を防ぐ。
                        $s[ $field ] = array_key_first( $available );
                    }
                }
            }
        }

        AIAP_AI_Client::update_settings( $s );

        // 保存時に即再チェック: 切替後の保存中モデルを最新のモデル一覧と突き合わせ、
        // discontinued ステータスを即時更新する。これをしないと週次 cron 実行まで
        // 古い警告 notice が残り続ける (上の自動切替で保存値は現行モデルになっても、
        // STATUS_OPT は cron 経路でしか更新されないため)。
        if ( class_exists( 'AIAP_Model_Sync' ) ) {
            foreach ( AIAP_AI_Providers::get_providers() as $provider ) {
                $key_field = AIAP_AI_Providers::get_key_field( $provider );
                $api_key   = $key_field !== '' ? (string) ( $s[ $key_field ] ?? '' ) : '';
                $model     = (string) ( $s[ $provider . '_model' ] ?? '' );
                AIAP_Model_Sync::refresh_status_for( $provider, $api_key, $model );
            }
        }

        AIAP_Admin::redirect( AIAP_Tabs::PROVIDER . '&sub=' . $tab, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    public static function handle_save_image_provider() {
        AIAP_Admin::verify();
        $im  = AIAP_Image_Client::get_settings();
        $tab = sanitize_key( wp_unslash( $_POST['image_tab'] ?? 'dalle3' ) );
        if ( ! isset( AIAP_Admin::$img_icons[ $tab ] ) ) $tab = 'dalle3';
        $src = array_keys( AIAP_Image_Client::get_sources() );
        if ( isset( $_POST['image_source'] ) ) {
            $posted_src = sanitize_text_field( wp_unslash( $_POST['image_source'] ) );
            if ( in_array( $posted_src, $src, true ) ) {
                $im['image_source'] = $posted_src;
            }
        }
        // APIキー: 空送信は「未変更」扱いで既存値を保持 (新方式 value="" + AJAX reveal と整合)
        $im = self::merge_posted_api_keys( $im, AIAP_Image_Client::get_key_fields(), $has_new_api_key );
        if ( $has_new_api_key ) {
            $crypto_ok = AIAP_Crypto::ensure_available();
            if ( is_wp_error( $crypto_ok ) ) {
                AIAP_Admin::redirect( AIAP_Tabs::IMAGE_PROVIDER . '&sub=' . $tab, $crypto_ok->get_error_message(), 'error' );
                return;
            }
        }
        if ( isset( $_POST['stability_model'] ) ) $im['stability_model'] = sanitize_text_field( wp_unslash( $_POST['stability_model'] ) );
        if ( isset( $_POST['flux_model'] ) )       $im['flux_model']      = sanitize_text_field( wp_unslash( $_POST['flux_model'] ) );

        // 画像プロバイダ(dalle3/imagen)のモデル選択保存 (スタイル検証より前に確定させる)
        foreach ( array( 'dalle3', 'imagen' ) as $prov ) {
            $field = $prov . '_model';
            if ( isset( $_POST[ $field ] ) ) {
                $im[ $field ] = sanitize_text_field( wp_unslash( $_POST[ $field ] ) );
            }
        }

        // Bug fix (テキスト側と対称): discontinued モデルを保存しようとした場合、
        // Registry default に自動切替する。dalle3 / imagen のみ /models 動的取得対象なので
        // 両者だけを判定 (stability / flux は Registry 静的定義しか無いため対象外)。
        if ( class_exists( 'AIAP_Model_Registry' ) ) {
            foreach ( array( 'dalle3', 'imagen' ) as $prov ) {
                $field = $prov . '_model';
                $value = isset( $im[ $field ] ) ? (string) $im[ $field ] : '';
                if ( $value === '' ) {
                    continue;
                }
                $available = AIAP_AI_Providers::get_models_for( $prov );
                if ( is_array( $available ) && ! empty( $available ) && ! isset( $available[ $value ] ) ) {
                    $default_model = AIAP_Model_Registry::default_image_model( $prov );
                    if ( $default_model !== '' && isset( $available[ $default_model ] ) ) {
                        $im[ $field ] = $default_model;
                    } else {
                        // Registry default 不在時は先頭フォールバック (テキスト側と対称)
                        $im[ $field ] = array_key_first( $available );
                    }
                }
            }
        }

        // プロバイダごとのスタイル保存 (そのプロバイダの現在モデルがサポートするスタイルのみ受け入れる)
        foreach ( array( 'dalle3', 'imagen', 'stability', 'flux' ) as $prov ) {
            $field = $prov . '_style';
            if ( isset( $_POST[ $field ] ) ) {
                $val = sanitize_text_field( wp_unslash( $_POST[ $field ] ) );
                $current_model = $im[ $prov . '_model' ] ?? '';
                $allowed = array_keys( AIAP_Image_Styles::get_provider_styles( $prov, $current_model ) );
                if ( in_array( $val, $allowed, true ) ) {
                    $im[ $field ] = $val;
                }
                // サポート外の値が送られてきた場合は保存せず既存値を維持
            }
        }

        // 仕様変更: 「デフォルト画像ソースとして使用する」トグルを廃止したため、
        // image_enabled は常に 1 に固定する。タスク/手動側の featured_image_mode が
        // 'on'/'off' の二択で個別に制御する。
        $im['image_enabled'] = 1;
        AIAP_Image_Client::update_settings( $im );
        AIAP_Admin::redirect( AIAP_Tabs::IMAGE_PROVIDER . '&sub=' . $tab, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    public static function handle_save_image_settings() {
        AIAP_Admin::verify();
        $im = AIAP_Image_Client::get_settings();
        // image_preset は UI ヒント。許可リストで検証。
        $allowed_presets = array( 'blog', 'sns', 'lite', 'custom' );
        $posted_preset   = sanitize_text_field( wp_unslash( $_POST['image_preset'] ?? 'custom' ) );
        $im['image_preset'] = in_array( $posted_preset, $allowed_presets, true ) ? $posted_preset : 'custom';

        // image_format / image_quality は許可リストで厳密検証
        $posted_format  = sanitize_text_field( wp_unslash( $_POST['image_format'] ?? '16:9' ) );
        $posted_quality = sanitize_text_field( wp_unslash( $_POST['image_quality'] ?? 'low' ) );
        $im['image_format']    = in_array( $posted_format, AIAP_Admin::$image_format_keys, true ) ? $posted_format : '16:9';
        $im['image_quality']   = in_array( $posted_quality, AIAP_Admin::$image_quality_keys, true ) ? $posted_quality : 'low';
        $im['image_max_width'] = max( 100, min( 4096, absint( wp_unslash( $_POST['image_max_width'] ?? 1200 ) ) ) );
        $im['image_compress']  = max( 1, min( 100, absint( wp_unslash( $_POST['image_compress'] ?? 85 ) ) ) );
        AIAP_Image_Client::update_settings( $im );
        AIAP_Admin::redirect( AIAP_Tabs::IMAGE_SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    public static function handle_save_system_prompt() {
        AIAP_Admin::verify();
        $s = AIAP_AI_Client::get_settings();
        $posted_lang = sanitize_text_field( wp_unslash( $_POST['content_language'] ?? 'ja' ) );
        // 言語コードは get_language_options のキーと一致するもののみ受け入れる
        $valid_langs = array_keys( AIAP_AI_Client::get_language_options() );
        $s['content_language'] = in_array( $posted_lang, $valid_langs, true ) ? $posted_lang : 'ja';
        $s['content_length']   = isset( $_POST['content_length'] ) && wp_unslash( $_POST['content_length'] ) !== ''
            ? max( 0, min( 10000, absint( wp_unslash( $_POST['content_length'] ) ) ) )
            : 0;
        $s['system_prompt']    = sanitize_textarea_field( wp_unslash( $_POST['system_prompt'] ?? '' ) );
        AIAP_AI_Client::update_settings( $s );
        // Bug fix: 旧実装は 'prompts' (タスク一覧タブ) にリダイレクトしていたため、
        // システムプロンプト保存後にタスクメニューへ飛んでいた。System Prompt は Phase 9 で
        // 独立タブ ('system_prompt') に分離済みなので、保存後は自タブに戻す。
        AIAP_Admin::redirect( AIAP_Tabs::SYSTEM_PROMPT, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    /**
     * 「投稿完了通知」セクションの保存。
     * 通知有効化トグル + 通知先メアド + SMTP 設定 (host/port/encryption/auth/username/password/from) を保存する。
     * パスワードは AIAP_Notifier::save_settings 内で AIAP_Crypto::encrypt によって暗号化される (L25 機密情報保護)。
     */
    public static function handle_save_notify() {
        AIAP_Admin::verify();
        if ( ! class_exists( 'AIAP_Notifier' ) ) {
            AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Notifier is not available.', 'ai-auto-poster' ), 'error' );
            return;
        }
        $result = AIAP_Notifier::save_settings( $_POST );
        if ( ! empty( $result['saved'] ) ) {
            AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, __( 'Settings saved.', 'ai-auto-poster' ) );
        } else {
            $err = isset( $result['error'] ) ? (string) $result['error'] : __( 'Failed to save settings.', 'ai-auto-poster' );
            AIAP_Admin::redirect( AIAP_Tabs::SETTINGS, $err, 'error' );
        }
    }

    public static function handle_add_task() {
        self::handle_save_task( 'add' );
    }

    public static function handle_edit_task() {
        self::handle_save_task( 'edit' );
    }

    /**
     * Dedup: handle_add_task / handle_edit_task の共通フロー。
     *
     * 旧実装は両ハンドラで verify → read_task_form → prompt 空チェック → 言語チェック →
     * AIAP_Prompts::(add|update) → invalid_schedule 警告 → redirect、をほぼ同形にコピペ
     * していた (CLAUDE.md「同一コード・重複処理が存在する場合は試験結果NG」)。
     *
     * @param string $mode 'add' / 'edit'
     */
    private static function handle_save_task( $mode ) {
        AIAP_Admin::verify();
        $form = self::read_task_form();
        $data = $form['data'];
        $task_id = ( $mode === 'edit' ) ? sanitize_text_field( wp_unslash( $_POST['task_id'] ?? '' ) ) : '';

        if ( $mode === 'edit' && empty( $task_id ) ) {
            AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, __( 'Prompt is required.', 'ai-auto-poster' ), 'error' );
            return;
        }
        if ( empty( $data['prompt'] ) ) {
            AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, __( 'Prompt is required.', 'ai-auto-poster' ), 'error' );
            return;
        }
        $valid_langs = array_keys( AIAP_AI_Client::get_language_options() );
        if ( ! in_array( $data['content_language'], $valid_langs, true ) ) {
            AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, __( 'Content language is required.', 'ai-auto-poster' ), 'error' );
            return;
        }

        if ( $mode === 'add' ) {
            $new_task_id = AIAP_Prompts::add(
                $data['prompt'], $data['schedule'], $data['enabled'], $data['content_language'],
                $data['content_length'], $data['featured_image_mode'], $data['random_mode'],
                $data['random_count_min'], $data['random_count_max'], $data['allowed_periods'],
                $data['min_interval_minutes'], $data['name']
            );
            // RSS settings belong to the task ID returned by add().
            if ( is_string( $new_task_id ) && $new_task_id !== '' ) {
                AIAP_Prompts::update( $new_task_id, self::extract_task_rss_data( $data ) );
            }
        } else {
            AIAP_Prompts::update( $task_id, $data );
        }

        if ( $form['invalid_schedule'] > 0 ) {
            /* translators: %d = number of invalid schedule entries discarded */
            $msg = sprintf( __( 'Settings saved. %d invalid schedule time(s) were discarded. Use HH:MM (e.g. 09:00).', 'ai-auto-poster' ), $form['invalid_schedule'] );
            AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, $msg, 'warning' );
            return;
        }
        AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, __( 'Settings saved.', 'ai-auto-poster' ) );
    }

    public static function handle_toggle_task() { AIAP_Admin::verify(); AIAP_Prompts::toggle( sanitize_text_field( wp_unslash( $_POST['task_id'] ?? '' ) ) ); AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, __( 'Settings saved.', 'ai-auto-poster' ) ); }
    public static function handle_delete_task() { AIAP_Admin::verify(); AIAP_Prompts::delete( sanitize_text_field( wp_unslash( $_POST['task_id'] ?? '' ) ) ); AIAP_Admin::redirect( AIAP_Tabs::PROMPTS, __( 'Settings saved.', 'ai-auto-poster' ) ); }
}
