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

/**
 * HTTP 呼出の共通ヘルパ
 *
 * プラグイン内 33 箇所で散在していた wp_remote_post / wp_remote_get の定型処理
 * (timeout 指定、headers 構築、is_wp_error チェック、response_code/body 取得、
 * JSON デコード) を集約。各 call site の重複コードを約 4-5 行 → 1 行呼び出しに短縮。
 *
 * 戻り値の構造を統一することで、API 呼出ロジックを単純化:
 *   - 成功時: array( 'code' => int, 'body' => string, 'json' => mixed|null, ... )
 *   - 失敗時: WP_Error (is_wp_error() で判定可能)
 *
 * call_with_retry とは独立に動作。リトライが必要な箇所は引き続き
 * AIAP_AI_Client::generate_with_retry / AIAP_Image_Providers::call_with_retry
 * の callable 内で AIAP_Http を呼ぶ。
 */
class AIAP_Http {

    private static function response_to_result( $response ) {
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $text = wp_remote_retrieve_body( $response );
        return array(
            'code'     => (int) wp_remote_retrieve_response_code( $response ),
            'body'     => $text,
            'json'     => json_decode( $text, true ),
            'response' => $response,
        );
    }

    /**
     * JSON POST リクエスト
     *
     * 主に AI API (Claude / OpenAI / Gemini / Imagen / Stability / Flux 等) への
     * リクエスト送信に使用。body を自動で wp_json_encode し、Content-Type: application/json
     * を自動付与する。
     *
     * @param string $url     リクエスト先 URL
     * @param array  $body    リクエストボディ (配列のまま渡す。内部で wp_json_encode)
     * @param array  $headers 追加ヘッダ (Authorization など)。Content-Type は自動付与
     * @param int    $timeout タイムアウト秒数 (デフォルト 120 秒、AI 生成系を想定)
     * @return array|WP_Error 成功時: ['code' => int, 'body' => string, 'json' => mixed|null, 'response' => array]
     *                        失敗時: WP_Error
     *                        'response' は元の wp_remote_post の戻り値そのまま
     *                        (AIAP_AI_Errors::build_api_error_wp_error などで利用するため残す)
     */
    public static function post_json( $url, $body, $headers = array(), $timeout = 120 ) {
        // Bug fix (SSRF): redirect 先が内部 URL に飛ばされる経路を WordPress 内蔵の
        // reject_unsafe_urls で塞ぐ。redirection は最大 3 ホップに制限 (デフォルト 5 は冗長)。
        $response = wp_remote_post( $url, array(
            'timeout'            => $timeout,
            'redirection'        => 3,
            'reject_unsafe_urls' => true,
            'headers'            => array_merge(
                array( 'Content-Type' => 'application/json' ),
                $headers
            ),
            'body' => wp_json_encode( $body ),
        ) );
        return self::response_to_result( $response );
    }

    /**
     * 任意の Content-Type / body の POST リクエスト
     *
     * multipart/form-data など JSON 以外の形式が必要な箇所で使う
     * (Stability AI など)。Content-Type は呼び出し側で headers に含める。
     *
     * @param string $url     リクエスト先 URL
     * @param string $body    送信ボディ (生バイト列)
     * @param array  $headers ヘッダ (Authorization, Content-Type 等)
     * @param int    $timeout タイムアウト秒数 (デフォルト 120 秒、画像生成系を想定)
     * @return array|WP_Error 成功時: ['code' => int, 'body' => string, 'json' => mixed|null, 'response' => array]
     *                        失敗時: WP_Error
     */
    public static function post_raw( $url, $body, $headers = array(), $timeout = 120 ) {
        // Bug fix (SSRF): post_json と同じく redirect 経路の内部 URL を弾く。
        $args = array(
            'timeout'            => $timeout,
            'redirection'        => 3,
            'reject_unsafe_urls' => true,
            'body'               => $body,
        );
        if ( ! empty( $headers ) ) {
            $args['headers'] = $headers;
        }
        $response = wp_remote_post( $url, $args );
        return self::response_to_result( $response );
    }

    /**
     * GET リクエスト
     *
     * モデル一覧取得、画像ダウンロード、外部 API クエリなどに使用。
     * headers が空の場合は headers キーを送信しない (元コードとの互換のため)。
     *
     * @param string $url     リクエスト先 URL
     * @param array  $headers 追加ヘッダ (空なら未指定)
     * @param int    $timeout タイムアウト秒数 (デフォルト 20 秒、メタ情報取得を想定)
     * @return array|WP_Error 成功時: ['code' => int, 'body' => string, 'json' => mixed|null, 'response' => array]
     *                        失敗時: WP_Error
     *                        'response' は元の wp_remote_get の戻り値そのまま (画像 DL 等で必要な場合用)
     */
    public static function get( $url, $headers = array(), $timeout = 20 ) {
        // Bug fix (SSRF): post_json と同じく redirect 経路の内部 URL を弾く。
        $args = array(
            'timeout'            => $timeout,
            'redirection'        => 3,
            'reject_unsafe_urls' => true,
        );
        if ( ! empty( $headers ) ) {
            $args['headers'] = $headers;
        }
        $response = wp_remote_get( $url, $args );
        return self::response_to_result( $response );
    }

    /**
     * Bearer 認証ヘッダを生成 (OpenAI / Stability / Flux など多数の API で共通)
     *
     * @param string $token API トークン
     * @return array ['Authorization' => 'Bearer xxx']
     */
    public static function bearer( $token ) {
        return array( 'Authorization' => 'Bearer ' . $token );
    }

    /**
     * x-api-key 認証ヘッダを生成 (Anthropic Claude 等で使用)
     *
     * @param string $key API キー
     * @return array ['x-api-key' => 'xxx']
     */
    public static function x_api_key( $key ) {
        return array( 'x-api-key' => $key );
    }

    /**
     * SSRF 防止: 外部宛 URL の安全性をチェックする
     *
     * 検査内容:
     *   - スキームが http / https のみ
     *   - ホスト名から解決される全 IP がプライベート / 予約 / ループバック /
     *     リンクローカル (AWS メタデータ 169.254.169.254 等) でないこと
     *
     * 利用箇所:
     *   - AIAP_RSS_Feeds::test_connection / pick_unused (任意 URL 接続テスト・取得)
     *   - AIAP_Image_Client (画像ダウンロード)
     *
     * 注意:
     *   - DNS 解決失敗時は false を返す (= 解決できない URL は弾く)。
     *     旧実装は `@gethostbynamel` で失敗時を素通りさせていたためここを厳格化する。
     *   - 直接 IP リテラルが入った URL もチェック対象。
     *
     * @param string $url 検査対象 URL
     * @return true|WP_Error 安全なら true、危険なら WP_Error
     */
    public static function is_safe_external_url( $url ) {
        $url = (string) $url;
        if ( $url === '' || ! function_exists( 'wp_http_validate_url' ) || ! wp_http_validate_url( $url ) ) {
            return new WP_Error( 'aiap_url_invalid', __( 'URL is invalid.', 'ai-auto-poster' ) );
        }
        $parts = wp_parse_url( $url );
        if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
            return new WP_Error( 'aiap_url_invalid', __( 'URL is invalid.', 'ai-auto-poster' ) );
        }
        $scheme = strtolower( $parts['scheme'] );
        if ( $scheme !== 'http' && $scheme !== 'https' ) {
            return new WP_Error( 'aiap_url_scheme', __( 'Only http/https URLs are allowed.', 'ai-auto-poster' ) );
        }
        $host = (string) $parts['host'];
        // ホスト自体が IP リテラルの場合の判定を先に行う
        $ips = array();
        if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
            $ips[] = $host;
        } else {
            // 明らかな内部名は DNS 解決前に弾く
            $lower = strtolower( $host );
            if ( $lower === 'localhost' || substr( $lower, -6 ) === '.local' ) {
                return new WP_Error( 'aiap_url_internal', __( 'Internal hostnames are not allowed.', 'ai-auto-poster' ) );
            }
            // IPv4 解決
            if ( function_exists( 'gethostbynamel' ) ) {
                $resolved = gethostbynamel( $host );
                if ( is_array( $resolved ) ) {
                    $ips = array_merge( $ips, $resolved );
                }
            }
            // IPv6 解決 (オプション、利用可能な場合のみ)
            if ( function_exists( 'dns_get_record' ) ) {
                $aaaa = @dns_get_record( $host, DNS_AAAA );
                if ( is_array( $aaaa ) ) {
                    foreach ( $aaaa as $rec ) {
                        if ( isset( $rec['ipv6'] ) ) {
                            $ips[] = $rec['ipv6'];
                        }
                    }
                }
            }
            // DNS 解決できなかった場合は安全側で拒否する (旧実装の素通りバグへの対策)
            if ( empty( $ips ) ) {
                return new WP_Error( 'aiap_url_unresolvable', __( 'Could not resolve hostname.', 'ai-auto-poster' ) );
            }
        }
        foreach ( $ips as $ip ) {
            // FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE で
            // RFC1918 プライベート (10/8, 172.16/12, 192.168/16) と
            // 予約 (127/8, 169.254/16, multicast 等) の両方を弾く。
            $public_ok = filter_var(
                $ip,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            );
            if ( $public_ok === false ) {
                return new WP_Error( 'aiap_url_internal', __( 'URL resolves to an internal address.', 'ai-auto-poster' ) );
            }
        }
        return true;
    }
}
