<?php

if ( ! defined( 'ABSPATH' ) ) {

    exit;

}



/**

 * APIキーの暗号化/復号化

 *

 * WordPress の AUTH_KEY 由来の鍵で API キー・SMTP パスワード等を暗号化し、

 * wp_options に平文を保存しないようにする。

 *

 * 暗号化方式:

 *   - 新規 (v2): AES-256-GCM (認証付き暗号、改ざん検知可)

 *   - 旧 (v1) : AES-256-CBC (認証なし、復号互換のため残置)

 *

 * 暗号文フォーマット:

 *   - v2: 'aiap_v2:' + base64( iv(12) || tag(16) || ciphertext )

 *   - v1: 'aiap_v1:' + base64( iv(16) || ciphertext )  (旧データ復号互換)

 *

 * 鍵導出:

 *   - hash_hkdf( 'sha256', AUTH_KEY, 32, label, SECURE_AUTH_KEY ) を使い、

 *     用途ごとに独立した鍵を生成する。AUTH_KEY を直接鍵に流用しないことで

 *     他用途との鍵の混用リスクを下げる。

 *

 * セキュリティ:

 *  - 認証付き暗号 (GCM) により、暗号文の改ざんを検知できる (旧 CBC は不能)。

 *  - AUTH_KEY 未定義環境では暗号化を行わない (平文保存は明示拒否)。

 */

class AIAP_Crypto {



    const METHOD_GCM      = 'aes-256-gcm';

    const METHOD_CBC      = 'aes-256-cbc';   // 旧暗号文の復号にのみ使用

    const CIPHER_PREFIX_V2 = 'aiap_v2:';

    const CIPHER_PREFIX_V1 = 'aiap_v1:';

    // 新規暗号化は常に v2 を使う。旧コードから参照されている定数は v1 のまま残す。

    const CIPHER_PREFIX    = 'aiap_v1:';

    const GCM_IV_LEN       = 12;

    const GCM_TAG_LEN      = 16;



    /**

     * 暗号化失敗をログに残し、false を返す共通ヘルパ。

     *

     * 旧実装は encrypt 経路の各 if ブロックで同じ

     * `if ( class_exists('AIAP_Logger') ) { AIAP_Logger::add('error','log_crypto_encrypt_failed'); }`

     * を独立に書いていた (CLAUDE.md 重複排除)。

     *

     * @return false

     */

    private static function fail_encrypt() {

        if ( class_exists( 'AIAP_Logger' ) ) {

            AIAP_Logger::add( 'error', 'log_crypto_encrypt_failed' );

        }

        return false;

    }



    /**

     * 復号失敗をログに残し、空文字を返す共通ヘルパ。

     *

     * @return string '' (空文字固定)

     */

    private static function fail_decrypt() {

        if ( class_exists( 'AIAP_Logger' ) ) {

            AIAP_Logger::add( 'error', 'log_crypto_decrypt_failed' );

        }

        return '';

    }



    /**

     * 用途別の暗号化キーを導出する

     *

     * バージョン別の鍵導出方針:

     *   - v1 (旧 CBC): 旧コードと同じ `sha256(AUTH_KEY . '|' . $salt . '|aiap_v1')`

     *     を必ず使う。これは既存サイトの DB に保存済みの v1 暗号文を復号するために、

     *     **後方互換性を絶対に維持しなければならない**。HKDF に切り替えると、

     *     v1 暗号文が「AUTH_KEY 変わった」エラーで復号不能になる致命的回帰となる。

     *   - v2 (現行 GCM): HKDF で導出 (鍵分離のためエントロピー強化)。

     *     v2 は本コードから新規に書き込まれた暗号文しか対象にしないため、

     *     後方互換の問題はない。HKDF 非対応環境では旧 sha256 連結にフォールバック。

     *

     * @param string $version 'v2' (現行) または 'v1' (旧 CBC 互換)

     * @return string|false 32 バイトの鍵、または AUTH_KEY 未定義時 false

     */

    private static function get_key( $version = 'v2' ) {

        if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === '' ) {

            return false;

        }

        $salt = defined( 'SECURE_AUTH_KEY' ) && SECURE_AUTH_KEY !== '' ? SECURE_AUTH_KEY : 'aiap_salt';



        // v1 は旧コードと厳密に同じ鍵を返す (既存暗号文の復号互換のため変更不可)。

        if ( $version === 'v1' ) {

            return hash( 'sha256', AUTH_KEY . '|' . $salt . '|aiap_v1', true );

        }



        // v2 は HKDF を使って鍵を分離。HKDF 非対応環境は v2 専用の sha256 フォールバック。

        $info = 'aiap-keys-' . $version;

        if ( function_exists( 'hash_hkdf' ) ) {

            $derived = hash_hkdf( 'sha256', AUTH_KEY, 32, $info, $salt );

            if ( $derived !== false && strlen( $derived ) === 32 ) {

                return $derived;

            }

        }

        return hash( 'sha256', AUTH_KEY . '|' . $salt . '|aiap_' . $version, true );

    }



    /**

     * Bug fix: HKDF 有無で鍵が変わるため、PHP 7.1.0 以下と 7.1.1 以上の間で DB を移動すると

     * v2 暗号文の復号がすべて失敗する。復号時のみ「HKDF 鍵 → 失敗なら sha256 フォールバック鍵」

     * の二段構えで試す (v1 互換と同じパターン)。新規暗号化は常に主鍵 (HKDF 利用可なら HKDF) を使う。

     *

     * @param string $version v2 のみ想定

     * @return array<int,string> 試行順の鍵候補 (重複は除外)

     */

    private static function get_decrypt_key_candidates( $version ) {

        if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === '' ) {

            return array();

        }

        $salt = defined( 'SECURE_AUTH_KEY' ) && SECURE_AUTH_KEY !== '' ? SECURE_AUTH_KEY : 'aiap_salt';

        $candidates = array();

        $info = 'aiap-keys-' . $version;

        if ( function_exists( 'hash_hkdf' ) ) {

            $derived = hash_hkdf( 'sha256', AUTH_KEY, 32, $info, $salt );

            if ( $derived !== false && strlen( $derived ) === 32 ) {

                $candidates[] = $derived;

            }

        }

        $fallback = hash( 'sha256', AUTH_KEY . '|' . $salt . '|aiap_' . $version, true );

        if ( ! in_array( $fallback, $candidates, true ) ) {

            $candidates[] = $fallback;

        }

        return $candidates;

    }



    /**

     * CSPRNG ベースの乱数バイト列を取得

     *

     * @param int $len バイト数

     * @return string

     */

    private static function random_bytes_safe( $len ) {

        // random_bytes は CSPRNG 利用不能時 PHP 7+ で \Error を投げるため Throwable で受ける。

        // 旧実装は \Exception のみで Error を取り逃していた。

        try {

            return random_bytes( $len );

        } catch ( \Throwable $e ) {

            return openssl_random_pseudo_bytes( $len );

        }

    }



    /**

     * 文字列を暗号化 (AES-256-GCM)

     *

     * 暗号化に失敗した場合は false を返す(平文を返さない)。

     * 呼び出し側は false を受けたら API キーを保存しない判断をすること。

     *

     * @param string $plain 平文

     * @return string|false 暗号文 (プレフィックス + Base64)、空入力時は ''、暗号化失敗時は false

     */

    public static function encrypt( $plain ) {

        if ( $plain === '' ) {

            return '';

        }

        if ( ! function_exists( 'openssl_encrypt' ) ) {

            return self::fail_encrypt();

        }

        $key = self::get_key( 'v2' );

        if ( $key === false ) {

            return self::fail_encrypt();

        }



        // GCM のサポート可否を実行時にチェック (古い openssl では未対応の可能性)

        $methods = openssl_get_cipher_methods( true );

        if ( ! is_array( $methods ) || ! in_array( self::METHOD_GCM, $methods, true ) ) {

            // GCM 非対応環境: v1 (CBC) にフォールバック。

            // 認証は失われるが「平文を DB に書き込まない」要件は満たす。

            return self::encrypt_v1_cbc( $plain );

        }



        $iv  = self::random_bytes_safe( self::GCM_IV_LEN );

        $tag = '';

        $encrypted = openssl_encrypt( $plain, self::METHOD_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag, '', self::GCM_TAG_LEN );

        if ( $encrypted === false || ! is_string( $tag ) || strlen( $tag ) !== self::GCM_TAG_LEN ) {

            return self::fail_encrypt();

        }



        return self::CIPHER_PREFIX_V2 . base64_encode( $iv . $tag . $encrypted );

    }



    /**

     * v1 (AES-256-CBC) 暗号化 - GCM 非対応環境向けフォールバック

     */

    private static function encrypt_v1_cbc( $plain ) {

        $key = self::get_key( 'v1' );

        if ( $key === false ) {

            return false;

        }

        $iv_len    = openssl_cipher_iv_length( self::METHOD_CBC );

        $iv        = self::random_bytes_safe( $iv_len );

        $encrypted = openssl_encrypt( $plain, self::METHOD_CBC, $key, OPENSSL_RAW_DATA, $iv );

        if ( $encrypted === false ) {

            return self::fail_encrypt();

        }

        return self::CIPHER_PREFIX_V1 . base64_encode( $iv . $encrypted );

    }



    /**

     * 暗号文を復号化 (v2 / v1 自動判定)

     *

     * @param string $value 暗号文または平文

     * @return string 平文(復号成功時)、空文字(復号失敗時)、または入力値そのまま(暗号文でない時)

     */

    public static function decrypt( $value ) {

        if ( $value === '' || ! function_exists( 'openssl_decrypt' ) ) {

            return $value;

        }



        // 1.0.0 リリース前の内部形式 (base64("iv::ciphertext")) は復号できない

        if ( self::looks_like_legacy_cipher( $value ) ) {

            return self::fail_decrypt();

        }



        // v2 (GCM)

        if ( strpos( $value, self::CIPHER_PREFIX_V2 ) === 0 ) {

            return self::decrypt_v2_gcm( $value );

        }

        // v1 (CBC)

        if ( strpos( $value, self::CIPHER_PREFIX_V1 ) === 0 ) {

            return self::decrypt_v1_cbc( $value );

        }

        // プレフィックスなし: 平文 (暗号化前のレガシー値)

        return $value;

    }



    private static function decrypt_v2_gcm( $value ) {

        $candidates = self::get_decrypt_key_candidates( 'v2' );

        if ( empty( $candidates ) ) {

            return self::fail_decrypt();

        }

        $payload = substr( $value, strlen( self::CIPHER_PREFIX_V2 ) );

        $decoded = base64_decode( $payload, true );

        if ( $decoded === false ) {

            return '';

        }

        $min_len = self::GCM_IV_LEN + self::GCM_TAG_LEN + 1;

        if ( strlen( $decoded ) < $min_len ) {

            return '';

        }

        $iv         = substr( $decoded, 0, self::GCM_IV_LEN );

        $tag        = substr( $decoded, self::GCM_IV_LEN, self::GCM_TAG_LEN );

        $ciphertext = substr( $decoded, self::GCM_IV_LEN + self::GCM_TAG_LEN );

        // Bug fix: HKDF/フォールバック鍵の両方を試行。先頭が主鍵 (HKDF 利用可なら HKDF) で

        // 通常はそれで成功する。HKDF 有無を跨いだ移行ケースのみ二番目以降でヒット。

        foreach ( $candidates as $key ) {

            $plain = openssl_decrypt( $ciphertext, self::METHOD_GCM, $key, OPENSSL_RAW_DATA, $iv, $tag );

            if ( $plain !== false ) {

                return $plain;

            }

        }

        return self::fail_decrypt();

    }



    private static function decrypt_v1_cbc( $value ) {

        $key = self::get_key( 'v1' );

        if ( $key === false ) {

            return self::fail_decrypt();

        }

        $payload = substr( $value, strlen( self::CIPHER_PREFIX_V1 ) );

        $decoded = base64_decode( $payload, true );

        if ( $decoded === false ) {

            return '';

        }

        $iv_len = openssl_cipher_iv_length( self::METHOD_CBC );

        if ( strlen( $decoded ) <= $iv_len ) {

            return '';

        }

        $iv         = substr( $decoded, 0, $iv_len );

        $ciphertext = substr( $decoded, $iv_len );

        $plain      = openssl_decrypt( $ciphertext, self::METHOD_CBC, $key, OPENSSL_RAW_DATA, $iv );

        if ( $plain === false ) {

            return self::fail_decrypt();

        }

        return $plain;

    }



    /**

     * 旧形式の暗号文(base64 encoded "iv::ciphertext")を判定

     *

     * 1.0.0 リリース前の内部形式。実害はないが復号できないため、

     * 検出してユーザに再入力を促す目的で判定する。

     */

    private static function looks_like_legacy_cipher( $value ) {

        // base64 文字以外があったら違う

        if ( ! preg_match( '/^[A-Za-z0-9+\/=]+$/', $value ) ) return false;

        // 短すぎは違う

        if ( strlen( $value ) < 40 ) return false;

        $decoded = base64_decode( $value, true );

        if ( $decoded === false ) return false;

        // 旧形式は中に '::' が必ずあった

        if ( strpos( $decoded, '::' ) === false ) return false;

        // 念のため、新形式プレフィックスがあれば違う

        if ( strpos( $value, self::CIPHER_PREFIX ) === 0 ) return false;

        return true;

    }



    /**

     * 暗号化が利用可能な環境かを判定する。

     *

     * AUTH_KEY が定義されており、かつ openssl 拡張が利用可能な環境では暗号化/復号化が可能。

     * いずれかが欠けている環境では encrypt() / decrypt() が失敗する。呼び出し側はこのヘルパで

     * 早期判定し、API キー fields の既存値を温存する経路に分岐するのに使う。

     *

     * Bug fix (audit r9): AUTH_KEY 欠落環境で update_settings を呼ぶと、decrypt 失敗で空に

     * なった API キー fields が encrypt_keys でも空のまま素通りし、既存暗号文を空で上書きして

     * しまう問題を、呼び出し側で is_available() を見て分岐することで回避する。

     *

     * @return bool true = 暗号化/復号化可能

     */

    public static function is_available() {

        if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === '' ) {

            return false;

        }

        if ( ! function_exists( 'openssl_encrypt' ) || ! function_exists( 'openssl_decrypt' ) ) {

            return false;

        }

        return true;

    }



    /**

     * is_available() の WP_Error 化版。API キー保存箇所で「保存できるか」を事前にチェックして

     * UI にエラーを返すために使う。

     *

     * 旧実装: encrypt_keys() が失敗時に空文字に置換するため、呼び出し側で気付けず

     * 「保存成功」と UI 表示しつつ DB は空、というUXバグが発生していた。

     *

     * @return true|WP_Error true = 暗号化可能、WP_Error = 不可 (理由をユーザーに返す)

     */

    public static function ensure_available() {

        if ( self::is_available() ) {

            return true;

        }

        // 機密情報 (AUTH_KEY 値や openssl 詳細) は出さず、原因と対処だけ伝える

        if ( ! defined( 'AUTH_KEY' ) || AUTH_KEY === '' ) {

            return new WP_Error(

                'aiap_crypto_unavailable',

                __( 'API キーの暗号化保存に失敗しました。wp-config.php の AUTH_KEY が未設定です。サーバ管理者に確認してください。', 'ai-auto-poster' )

            );

        }

        return new WP_Error(

            'aiap_crypto_unavailable',

            __( 'API キーの暗号化保存に失敗しました。PHP の openssl 拡張が必要です。サーバ管理者に確認してください。', 'ai-auto-poster' )

        );

    }



    /**

     * 暗号化済みかどうか判定 (v1 / v2 両対応)

     */

    public static function is_encrypted( $value ) {

        if ( ! is_string( $value ) || $value === '' ) return false;

        if ( strpos( $value, self::CIPHER_PREFIX_V2 ) === 0 ) return true;

        if ( strpos( $value, self::CIPHER_PREFIX_V1 ) === 0 ) return true;

        return false;

    }



    /**

     * 設定配列内のAPIキーフィールドを暗号化

     *

     * @param array $settings   設定配列

     * @param array $key_fields 暗号化対象のフィールド名リスト

     * @return array 暗号化された設定配列

     */

    /**

     * 設定配列内の API キーを暗号化する。

     *

     * 暗号化に失敗した field は、ユーザー入力(平文)を保存させず、

     * 空文字に置き換える。これにより:

     *  - 平文 API キーが DB に書き込まれない

     *  - update_option() 後に当該 field は空 = ユーザーが再入力する必要があると分かる

     *

     * @param array $settings   設定配列

     * @param array $key_fields 暗号化対象のフィールド名リスト

     * @return array 暗号化された設定配列(失敗 field は '' に置換)

     */

    public static function encrypt_keys( $settings, $key_fields ) {

        foreach ( $key_fields as $field ) {

            if ( isset( $settings[ $field ] ) && $settings[ $field ] !== '' ) {

                // 既に暗号化済みなら再暗号化しない

                if ( ! self::is_encrypted( $settings[ $field ] ) ) {

                    $encrypted = self::encrypt( $settings[ $field ] );

                    if ( $encrypted === false ) {

                        // 暗号化失敗: 平文を保存させず空文字化する。ログは encrypt() 内で記録済み。

                        $settings[ $field ] = '';

                    } else {

                        $settings[ $field ] = $encrypted;

                    }

                }

            }

        }

        return $settings;

    }



    /**

     * 設定配列内のAPIキーフィールドを復号化

     *

     * @param array $settings   設定配列

     * @param array $key_fields 復号化対象のフィールド名リスト

     * @return array 復号化された設定配列

     */

    public static function decrypt_keys( $settings, $key_fields ) {

        foreach ( $key_fields as $field ) {

            if ( isset( $settings[ $field ] ) && $settings[ $field ] !== '' ) {

                $settings[ $field ] = self::decrypt( $settings[ $field ] );

            }

        }

        return $settings;

    }

}

