<?php
declare(strict_types=1);
namespace ConfManager\Security;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}


use ConfManager\Traits\Singleton;
use ConfManager\Traits\HasHooks;

/**
 * レート制限
 *
 * ログイン試行やAPI呼出に対するレート制限を実装。
 * WordPress Transient APIを使用してカウンターを管理。
 *
 * @since 1.0.0
 */
class RateLimiter {

	use Singleton;
	use HasHooks;

	/**
	 * 「転送ヘッダを無視した」警告を閉じたことを覚える wp_options キー
	 */
	private const PROXY_NOTICE_OPTION = 'conf_manager_trusted_proxy_notice_dismissed';

	/**
	 * その警告を閉じる admin-post アクション名
	 */
	private const PROXY_NOTICE_DISMISS_ACTION = 'conf_manager_dismiss_proxy_notice';

	/**
	 * レート制限ルール（アクション => [上限回数, ウィンドウ秒数, ロックアウト秒数]）
	 */
	private const RULES = [
		'login'             => [ 'limit' => 10, 'window' => 300,  'lockout' => 600 ],   // 10回/5分、超過で10分ロック
		// ⚠️ 2026-08-21 時点で呼び出し側は Free / Pro とも 0 件（パスキー認証ルートを削除したため）。
		// **それでも残す**理由: RateLimiter は**未定義のアクション名を黙って api_request
		// （60 回/1 分）へ落とす**ので、将来パスキー認証を実装し直した人が 'passkey_auth' と
		// 書いたとき、規則が無くてもエラーにならず**上限だけが静かに緩む**。
		// 規則を残しておけばその事故が起きない。**消すときは Free / Pro 同時に**
		// （handoff `notes/2026-08-21-free-to-pro-1.md` §C で 2 線合意）。
		// ⚠️ M-13 / M-14 は名前しか見ない＝**上限値そのものは守らない**。値は SecurityTest 側でアサートする。
		'passkey_auth'      => [ 'limit' => 10, 'window' => 300,  'lockout' => 600 ],   // 10回/5分、超過で10分ロック（**現在未使用**）
		'api_request'       => [ 'limit' => 60, 'window' => 60,   'lockout' => 120 ],   // 60回/1分、超過で2分ロック
		'member_lookup'     => [ 'limit' => 10, 'window' => 300,  'lockout' => 600 ],   // 10回/5分、超過で10分ロック
		'registration'      => [ 'limit' => 10, 'window' => 3600, 'lockout' => 3600 ],  // 10回/1時間、超過で1時間ロック
		// マイページの OTP 送信(IP基準)。大学・企業・学会会場など共有IPでは複数の正規利用者が
		// 同一IPを共有するため上限を緩め、ロックも短時間にする。個別の濫用は otp_per_email が抑止。
		'mypage_otp'        => [ 'limit' => 50, 'window' => 3600, 'lockout' => 600 ],   // 50回/1時間/IP、超過で10分ロック
		// 自己登録の OTP 送信(IP基準)。mypage_otp と同じ理由（共有 Wi-Fi・大学・会場の
		// NAT の内側は最初から 1 つの IP）で緩めるが、**50 にはしない**。
		// マイページ OTP は既存会員のアドレスにしか飛ばないのに対し、自己登録は
		// 入力された任意のアドレスへ送れるので、バウンス蓄積による送信レピュテーション
		// 毀損を抑えるため差をつける。30 なら 10 名が 3 回ずつ押しても通る。
		// ⭐ 上限より**ロック時間**のほうが効く（上限は「何人まで通るか」を決めるだけだが、
		//    ロック時間は「間違えたときサイトが何分死ぬか」を決める）。
		// 個別の濫用は otp_per_email（5 回/1時間/メール・据え置き）が抑止する。
		'self_register_otp' => [ 'limit' => 30, 'window' => 3600, 'lockout' => 600 ],   // 30回/1時間/IP、超過で10分ロック
		// 自己登録の OTP **入力**。以前は login の枠を流用していたため、同じ回線の誰かが
		// WordPress のログインに失敗しただけで自己登録のコード入力が止まっていた（逆も同じ）。
		// 総当たりの抑止はアドレス単位の試行回数（OTP_MAX_ATTEMPTS 5 / OTP_GLOBAL_MAX_ATTEMPTS 10）
		// が担うので、IP 側を緩めても 6 桁 OTP は 5 回で無効化される。
		'self_register_verify' => [ 'limit' => 30, 'window' => 300, 'lockout' => 600 ], // 30回/5分/IP、超過で10分ロック
		// メールキーのレート制限（IP非依存、分散攻撃対策）
		'otp_per_email'     => [ 'limit' => 5,  'window' => 3600, 'lockout' => 600 ],   // 5回/1時間/メール、超過で10分ロック（正規利用者の再送での長時間ロックを避ける。OTP爆撃の継続は引き続き抑止）
		'lookup_per_email'  => [ 'limit' => 10, 'window' => 3600, 'lockout' => 3600 ],  // 10回/1時間/メール
	];

	/**
	 * Cloudflare の IP レンジ（IPv4 15 / IPv6 7）
	 *
	 * 顧客が CDN を経由している可能性があるので、Cloudflare だけはレンジを
	 * 同梱して自動判定にする。それ以外の CDN / リバースプロキシは
	 * CONF_MANAGER_TRUSTED_PROXY で明示指定する。
	 *
	 * ⚠️ **これは保守項目。** 古くなると CF の新レンジから来た顧客が誤ロック
	 * されるが、そのときは管理画面に警告が出るので定数で復旧できる。
	 * 出典 https://www.cloudflare.com/ips/ （最終確認 2026-08-19）
	 */
	private const CLOUDFLARE_RANGES = [
		'173.245.48.0/20',
		'103.21.244.0/22',
		'103.22.200.0/22',
		'103.31.4.0/22',
		'141.101.64.0/18',
		'108.162.192.0/18',
		'190.93.240.0/20',
		'188.114.96.0/20',
		'197.234.240.0/22',
		'198.41.128.0/17',
		'162.158.0.0/15',
		'104.16.0.0/13',
		'104.24.0.0/14',
		'172.64.0.0/13',
		'131.0.72.0/22',
		'2400:cb00::/32',
		'2606:4700::/32',
		'2803:f800::/32',
		'2405:b500::/32',
		'2405:8100::/32',
		'2a06:98c0::/29',
		'2c0f:f248::/32',
	];

	/**
	 * 初期化
	 */
	protected function init(): void {
		$this->register_hooks();
	}

	/**
	 * フック登録
	 */
	protected function register_hooks(): void {
		// priority 0: 認証処理の最初にロックアウト中かチェック（カウントはしない）
		$this->add_filter( 'authenticate', [ $this, 'check_login_lockout' ], 0, 1 );
		// ログイン失敗時のみカウンターをインクリメント
		$this->add_action( 'wp_login_failed', [ $this, 'record_failed_login' ] );
		$this->add_action( 'admin_notices', [ $this, 'maybe_show_forwarded_header_notice' ] );
		$this->add_action( 'admin_post_' . self::PROXY_NOTICE_DISMISS_ACTION, [ $this, 'handle_dismiss_proxy_notice' ] );
	}

	/**
	 * レート制限をチェックする
	 *
	 * CONF_MANAGER_DISABLE_RATE_LIMIT 定数が true の場合のみスキップする。
	 *
	 * @param string      $action    アクション名（RULES のキー、またはカスタム）
	 * @param string|null $identifier 識別子（デフォルト: クライアントIP）
	 * @return true|\WP_Error 許可された場合true、制限超過の場合WP_Error
	 */
	public function check( string $action, ?string $identifier = null ): true|\WP_Error {
		// 明示的に無効化された場合のみレート制限をスキップ
		if ( defined( 'CONF_MANAGER_DISABLE_RATE_LIMIT' ) && CONF_MANAGER_DISABLE_RATE_LIMIT ) {
			return true;
		}

		$identifier = $identifier ?? $this->get_client_ip();
		$rule       = self::RULES[ $action ] ?? self::RULES['api_request'];

		// ロックアウト中か確認
		$lockout_key = $this->get_lockout_key( $action, $identifier );
		$locked_until = get_transient( $lockout_key );
		if ( false !== $locked_until ) {
			$remaining = (int) $locked_until - time();
			return new \WP_Error(
				'rate_limited',
				sprintf(
					/* translators: %d: remaining seconds */
					__( 'Too many requests. Please try again in %d seconds.', 'conferencemanager' ),
					max( 1, $remaining )
				),
				[ 'status' => 429, 'retry_after' => max( 1, $remaining ) ]
			);
		}

		// カウンターを取得・インクリメント
		$counter_key = $this->get_counter_key( $action, $identifier );
		$count       = (int) get_transient( $counter_key );

		if ( $count >= $rule['limit'] ) {
			// ロックアウトを設定
			set_transient( $lockout_key, time() + $rule['lockout'], $rule['lockout'] );

			// 🔴 **カウンターを捨てる。** 残したままだとロックが明けた直後の 1 回目が
			//    そのまま次のロックに入る。しかもカウンターの TTL は下の set_transient で
			//    毎回延びる（固定ウィンドウではなく「最後の 1 回から window 秒」）ので、
			//    アクセスが続く限り期限切れで自然に戻ることも無い＝**永久ロック**。
			delete_transient( $counter_key );

			// 監査ログに記録
			AuditLog::instance()->log( 'rate_limit_exceeded', 'security', null, [
				'action'     => $action,
				'identifier' => $identifier,
				'count'      => $count,
			] );

			return new \WP_Error(
				'rate_limited',
				sprintf(
					/* translators: %d: lockout duration in seconds */
					__( 'Too many requests. Please try again in %d seconds.', 'conferencemanager' ),
					$rule['lockout']
				),
				[ 'status' => 429, 'retry_after' => $rule['lockout'] ]
			);
		}

		// カウンターをインクリメント
		set_transient( $counter_key, $count + 1, $rule['window'] );

		return true;
	}

	/**
	 * IP + メール（または他の識別子）の複合レート制限チェック
	 *
	 * IPベースのアクションとメールベースのアクションを両方チェックする。
	 * 分散攻撃対策: 異なるIPからの同一メールへの大量リクエストを制限。
	 *
	 * @param string $ip_action    IP基準のアクション名
	 * @param string $email_action メール基準のアクション名
	 * @param string $email_hash   メールハッシュ（識別子）
	 * @return true|\WP_Error
	 */
	public function check_composite( string $ip_action, string $email_action, string $email_hash ): true|\WP_Error {
		// IP基準チェック
		$ip_check = $this->check( $ip_action );
		if ( is_wp_error( $ip_check ) ) {
			return $ip_check;
		}

		// メール基準チェック
		$email_check = $this->check( $email_action, $email_hash );
		if ( is_wp_error( $email_check ) ) {
			return $email_check;
		}

		return true;
	}

	/**
	 * ログイン時にロックアウト中かチェックする（カウントはしない）
	 *
	 * authenticate フィルターの priority 0 で実行し、
	 * ロックアウト中であれば早期に WP_Error を返す。
	 *
	 * @param \WP_User|\WP_Error|null $user
	 * @return \WP_User|\WP_Error|null
	 */
	public function check_login_lockout( \WP_User|\WP_Error|null $user ): \WP_User|\WP_Error|null {
		if ( defined( 'CONF_MANAGER_DISABLE_RATE_LIMIT' ) && CONF_MANAGER_DISABLE_RATE_LIMIT ) {
			return $user;
		}

		$identifier  = $this->get_client_ip();
		$lockout_key = $this->get_lockout_key( 'login', $identifier );
		$locked_until = get_transient( $lockout_key );

		if ( false !== $locked_until ) {
			$remaining = (int) $locked_until - time();
			return new \WP_Error(
				'rate_limited',
				sprintf(
					/* translators: %d: remaining seconds */
					__( 'Too many requests. Please try again in %d seconds.', 'conferencemanager' ),
					max( 1, $remaining )
				),
				[ 'status' => 429, 'retry_after' => max( 1, $remaining ) ]
			);
		}

		return $user;
	}

	/**
	 * ログイン失敗時にカウンターをインクリメントする
	 *
	 * wp_login_failed アクションで呼ばれるため、
	 * 成功したログインはカウントされない。
	 *
	 * @param string $username 失敗したユーザー名
	 */
	public function record_failed_login( string $username ): void {
		if ( defined( 'CONF_MANAGER_DISABLE_RATE_LIMIT' ) && CONF_MANAGER_DISABLE_RATE_LIMIT ) {
			return;
		}

		$identifier  = $this->get_client_ip();
		$rule        = self::RULES['login'];
		$lockout_key = $this->get_lockout_key( 'login', $identifier );

		// 🔴 ロック中は数えない。authenticate の priority 0 で弾いても wp_login_failed は
		//    発火するため、素通しだとロック中の試行がカウンターを積み直してしまう。
		if ( false !== get_transient( $lockout_key ) ) {
			return;
		}

		$counter_key = $this->get_counter_key( 'login', $identifier );
		$count       = (int) get_transient( $counter_key );
		$count++;

		set_transient( $counter_key, $count, $rule['window'] );

		if ( $count >= $rule['limit'] ) {
			set_transient( $lockout_key, time() + $rule['lockout'], $rule['lockout'] );

			// 🔴 check() と同じ理由でカウンターを捨てる（永久ロックの防止）。
			delete_transient( $counter_key );

			AuditLog::instance()->log( 'rate_limit_exceeded', 'security', null, [
				'action'     => 'login',
				'identifier' => $identifier,
				'count'      => $count,
				'username'   => $username,
			] );
		}
	}

	/**
	 * 残りのリクエスト数を取得
	 *
	 * @param string      $action     アクション名
	 * @param string|null $identifier 識別子
	 * @return int 残りの許可回数
	 */
	public function remaining( string $action, ?string $identifier = null ): int {
		$identifier = $identifier ?? $this->get_client_ip();
		$rule       = self::RULES[ $action ] ?? self::RULES['api_request'];

		// ロック中は 0。ロックを張るときにカウンターを捨てるので、
		// カウンターだけ見るとロック中なのに満額残って見える。
		if ( false !== get_transient( $this->get_lockout_key( $action, $identifier ) ) ) {
			return 0;
		}

		$counter_key = $this->get_counter_key( $action, $identifier );
		$count       = (int) get_transient( $counter_key );

		return max( 0, $rule['limit'] - $count );
	}

	/**
	 * レート制限をリセットする（管理者用）
	 *
	 * @param string $action     アクション名
	 * @param string $identifier 識別子
	 */
	public function reset( string $action, string $identifier ): void {
		delete_transient( $this->get_counter_key( $action, $identifier ) );
		delete_transient( $this->get_lockout_key( $action, $identifier ) );
	}

	/**
	 * カウンター用のTransientキーを生成
	 */
	private function get_counter_key( string $action, string $identifier ): string {
		return 'conf_rl_' . md5( $action . '|' . $identifier );
	}

	/**
	 * ロックアウト用のTransientキーを生成
	 */
	private function get_lockout_key( string $action, string $identifier ): string {
		return 'conf_rlock_' . md5( $action . '|' . $identifier );
	}

	/**
	 * クライアントIPアドレスを取得
	 *
	 * 旧実装は X-Forwarded-For / X-Real-IP を **無条件で最優先**に読み、
	 * さらに左端（`explode( ',' )[0]`）を採っていた。左端はクライアントが
	 * 自由に書けるので、`curl -H 'X-Forwarded-For: <任意の IP>'` の一行で
	 * 次の 4 つが成立していた。
	 *
	 *   - ログイン総当たり抑止（login 10 回/5 分）が無効化される
	 *   - **第三者の名指しロックアウト**（被害者の IP を詐称して失敗を積む）
	 *   - **監査ログ ip_address の偽装**（無実の第三者を指し、真の送信元は残らない）
	 *   - OTP 送信の IP 制限が無効化され、宛先を散らした一斉送信ができる
	 *
	 * 決定順は次のとおり。
	 *
	 *   REMOTE_ADDR が取れない（WP-CLI / Cron） → '0.0.0.0'
	 *   1. CONF_MANAGER_TRUSTED_PROXY / conf_manager_trusted_proxies に一致
	 *   2. REMOTE_ADDR が private・reserved（ローカルのリバースプロキシ配下）
	 *   3. REMOTE_ADDR が Cloudflare のレンジ内
	 *   4. それ以外 → REMOTE_ADDR のみ。転送ヘッダは捨てる
	 *
	 * ⚠️ **「REMOTE_ADDR だけ使う」に単純化してはいけない。** ローカルの
	 * リバースプロキシ配下（Xserver の X アクセラレータ等）では REMOTE_ADDR が
	 * 127.0.0.1 になり、**全利用者が同一 IP に集約**される。
	 * self_register_otp は 3 回/1 時間なので、サイト全体で 4 人目から誤発火する。
	 * 厳しくしたつもりで全員を締め出すことになる。
	 *
	 * @return string
	 */
	public function get_client_ip(): string {
		$remote = $this->remote_addr();
		if ( '' === $remote ) {
			// WP-CLI / Cron には接続元が無い。
			return '0.0.0.0';
		}

		$trusted = $this->trusted_proxies();
		if ( $this->connection_is_trusted( $remote, $trusted ) ) {
			$forwarded = $this->forwarded_candidate( $remote, $trusted );
			if ( null !== $forwarded ) {
				return $forwarded;
			}
		}

		return $remote;
	}

	/**
	 * 転送ヘッダを無視した結果が接続元と食い違っているか
	 *
	 * ⚠️ **「ヘッダが在るか」で判定してはいけない。** 前段にリバースプロキシが
	 * 無くても、ホスティング側や利用者側のネットワークが X-Forwarded-For を
	 * 付けてくることがある。Pro では v1.16.4 をこの形で出荷し、**本番で
	 * 誤報が出て、しかも閉じられなかった**（接続元は管理者本人のグローバル IP で、
	 * IP の判定そのものは正しく、ロックアウトの危険も無かった）。
	 *
	 * **無視した結果が REMOTE_ADDR と変わるときだけ**知らせる。これで
	 * 「ヘッダは来ているが値は接続元と同じ」という最も多い誤報の型が消える。
	 *
	 * @return bool
	 */
	public function forwarded_header_is_being_ignored(): bool {
		$remote = $this->remote_addr();
		if ( '' === $remote ) {
			return false;
		}

		$trusted = $this->trusted_proxies();
		if ( $this->connection_is_trusted( $remote, $trusted ) ) {
			// 信頼している経路なら値は採用済み。無視していないので知らせない。
			return false;
		}

		$forwarded = $this->forwarded_candidate( $remote, $trusted );

		return null !== $forwarded && $forwarded !== $remote;
	}

	/**
	 * 接続元（REMOTE_ADDR）を返す。取れない・不正なら空文字。
	 */
	public function remote_addr(): string {
		if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
			return '';
		}
		$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );

		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	/**
	 * この接続を「転送ヘッダを信じてよい相手」と見なすか
	 */
	private function connection_is_trusted( string $remote, array $trusted ): bool {
		return $this->ip_in_any( $remote, $trusted )
			|| $this->is_private_or_reserved( $remote )
			|| $this->ip_in_any( $remote, self::CLOUDFLARE_RANGES );
	}

	/**
	 * 信頼プロキシの一覧（CIDR または単一 IP）
	 *
	 * ⚠️ **定数は PHPUnit で切り替えられない**（定数は再定義できない）。
	 * そのため conf_manager_trusted_proxies フィルタを併設する。
	 * **フィルタを省くと、信頼プロキシ経路が丸ごと無検査になる。**
	 *
	 * @return string[]
	 */
	private function trusted_proxies(): array {
		$list = [];
		if ( defined( 'CONF_MANAGER_TRUSTED_PROXY' ) ) {
			$raw  = constant( 'CONF_MANAGER_TRUSTED_PROXY' );
			$list = is_array( $raw )
				? $raw
				: preg_split( '/[\s,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY );
		}

		/**
		 * 信頼するリバースプロキシ / CDN の CIDR 一覧。
		 *
		 * @param string[] $list CIDR または単一 IP の配列。
		 */
		$list = apply_filters( 'conf_manager_trusted_proxies', $list );

		if ( ! is_array( $list ) ) {
			return [];
		}

		return array_values( array_filter( array_map( 'strval', $list ), static fn( $v ) => '' !== trim( $v ) ) );
	}

	/**
	 * 転送ヘッダから実クライアントを取り出す（採用可否は呼び出し側が決める）
	 *
	 * ⚠️ **X-Forwarded-For は右から読む。** 「クライアント, プロキシ1, …」の順に
	 * 積まれ、**左端はクライアントが自由に書ける**。信頼できるホップ
	 * （private/reserved・信頼プロキシ・CF レンジ）を右から読み飛ばし、
	 * 最初に残った IP を採る。従来の `explode( ',' )[0]`（左端）は、
	 * プロキシが追記する構成でも偽装が通る。
	 *
	 * @param string   $remote  接続元。
	 * @param string[] $trusted 信頼プロキシの CIDR 一覧。
	 * @return string|null 取り出せなければ null。
	 */
	private function forwarded_candidate( string $remote, array $trusted ): ?string {
		// CF-Connecting-IP は Cloudflare 自身が付けるので、**CF から来たときだけ**見る。
		if ( $this->ip_in_any( $remote, self::CLOUDFLARE_RANGES ) ) {
			$cf = $this->server_ip( 'HTTP_CF_CONNECTING_IP' );
			if ( '' !== $cf ) {
				return $cf;
			}
		}

		$xff = isset( $_SERVER['HTTP_X_FORWARDED_FOR'] )
			? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) )
			: '';
		if ( '' !== $xff ) {
			$hops = array_map( 'trim', explode( ',', $xff ) );
			for ( $i = count( $hops ) - 1; $i >= 0; $i-- ) {
				$hop = $hops[ $i ];
				if ( ! filter_var( $hop, FILTER_VALIDATE_IP ) ) {
					continue;
				}
				if ( $this->is_private_or_reserved( $hop )
					|| $this->ip_in_any( $hop, $trusted )
					|| $this->ip_in_any( $hop, self::CLOUDFLARE_RANGES ) ) {
					continue;
				}
				return $hop;
			}
		}

		$real = $this->server_ip( 'HTTP_X_REAL_IP' );

		return '' !== $real ? $real : null;
	}

	/**
	 * $_SERVER から IP として妥当な値だけを取り出す
	 */
	private function server_ip( string $key ): string {
		if ( ! isset( $_SERVER[ $key ] ) ) {
			return '';
		}
		$ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Sanitized on this line; dynamic key not recognized by PHPCS.

		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	/**
	 * private / reserved なアドレスか
	 *
	 * 自前で CIDR を並べると 169.254/16 や fe80::/10 を取りこぼすので、
	 * filter_var のフラグに任せる。
	 */
	private function is_private_or_reserved( string $ip ): bool {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return false;
		}

		return ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
	}

	/**
	 * 一覧のいずれかに含まれるか
	 *
	 * @param string   $ip    判定する IP。
	 * @param string[] $cidrs CIDR または単一 IP の一覧。
	 */
	private function ip_in_any( string $ip, array $cidrs ): bool {
		foreach ( $cidrs as $cidr ) {
			if ( $this->ip_in_cidr( $ip, (string) $cidr ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * 転送ヘッダを無視していることを管理者に知らせる
	 *
	 * ⚠️ 表示するのは**その画面を開いた本人の接続元だけ**。他の管理者や
	 * 会員の IP は出さない。
	 *
	 * ⚠️ 第三者がヘッダを付けて偽の警告を出させることはできない
	 * （警告は管理者自身のリクエストでしか描画されない）。
	 */
	public function maybe_show_forwarded_header_notice(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( get_option( self::PROXY_NOTICE_OPTION, '' ) ) {
			return;
		}
		if ( ! $this->forwarded_header_is_being_ignored() ) {
			return;
		}

		$remote = $this->remote_addr();
		$cidr   = $remote . ( false !== strpos( $remote, ':' ) ? '/128' : '/32' );

		echo '<div class="notice notice-warning">';
		printf(
			'<p><strong>Conference Manager:</strong> %s</p>',
			esc_html__( 'A forwarding header was ignored for rate limiting and audit logging.', 'conferencemanager' )
		);
		printf(
			'<p>%s</p>',
			esc_html__( 'This request carried a forwarding header naming a different address than the connection it arrived on. The header is ignored because the connection did not come from a proxy this site trusts — otherwise anyone could name a third party and get them locked out, or write someone else into the audit log.', 'conferencemanager' )
		);
		printf(
			'<p>%s<br><code>%s</code></p>',
			esc_html__( 'If a CDN or reverse proxy really is in front of this site, add its address range to wp-config.php so real visitor addresses are used again. The range below is the connection this page was opened from:', 'conferencemanager' ),
			esc_html( sprintf( "define( 'CONF_MANAGER_TRUSTED_PROXY', '%s' );", $cidr ) )
		);

		// 状態を変える操作なので GET リンクにはしない（先読みで通ってしまう）。
		printf(
			'<form method="post" action="%s"><input type="hidden" name="action" value="%s" />',
			esc_url( admin_url( 'admin-post.php' ) ),
			esc_attr( self::PROXY_NOTICE_DISMISS_ACTION )
		);
		wp_nonce_field( self::PROXY_NOTICE_DISMISS_ACTION );
		printf(
			'<p><button type="submit" class="button-link">%s</button></p></form>',
			esc_html__( 'No proxy is in front of this site — stop showing this', 'conferencemanager' )
		);
		echo '</div>';
	}

	/**
	 * 警告を閉じる
	 *
	 * ⚠️ 一度閉じると、後から本当に CDN 配下へ移っても警告は出ない。
	 * そのときは wp_options の conf_manager_trusted_proxy_notice_dismissed を
	 * 消すか、CONF_MANAGER_TRUSTED_PROXY を設定すること。
	 */
	public function handle_dismiss_proxy_notice(): void {
		// ⚠️ **admin-post.php は $_REQUEST['action'] だけを見てここへ来る＝GET でも届く。**
		// フォームを POST にしても入口は塞がっていない。
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 方式の判定のみ。nonce は下で check_admin_referer() が検証する。
		// $_SERVER も入力なので素通しにしない（CLAUDE.md「$_SERVER 直接参照禁止」）。
		// リテラル 'POST' と比べるだけで注入余地は無いが、Plugin Check は
		// MissingUnslash / InputNotSanitized を WARNING で出す。
		$request_method = isset( $_SERVER['REQUEST_METHOD'] )
			? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
			: '';
		if ( 'POST' !== $request_method ) {
			wp_die(
				esc_html__( 'This action must be submitted with the button in the warning.', 'conferencemanager' ),
				'',
				[ 'response' => 405 ]
			);
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die(
				esc_html__( 'You do not have permission to do this.', 'conferencemanager' ),
				'',
				[ 'response' => 403 ]
			);
		}

		check_admin_referer( self::PROXY_NOTICE_DISMISS_ACTION );

		update_option( self::PROXY_NOTICE_OPTION, '1' );

		wp_safe_redirect( admin_url( 'admin.php?page=conferencemanager' ) );
		exit;
	}

	/**
	 * IP が CIDR に含まれるか（IPv4 / IPv6 とも）
	 */
	private function ip_in_cidr( string $ip, string $cidr ): bool {
		$cidr = trim( $cidr );
		if ( '' === $cidr ) {
			return false;
		}
		if ( false === strpos( $cidr, '/' ) ) {
			// 単一 IP の指定も許す（正規化して比較する）。
			$a = @inet_pton( $ip );
			$b = @inet_pton( $cidr );

			return false !== $a && false !== $b && $a === $b;
		}

		[ $subnet, $bits_raw ] = explode( '/', $cidr, 2 );
		$ip_bin  = @inet_pton( $ip );
		$sub_bin = @inet_pton( $subnet );
		if ( false === $ip_bin || false === $sub_bin || strlen( $ip_bin ) !== strlen( $sub_bin ) ) {
			// v4 と v6 は比較しない。
			return false;
		}

		// ⚠️ **`(int) ''` も `(int) 'abc'` も 0 になる。** 0 は `/0` として有効な
		// ビット数なので、そのまま数値化すると `198.51.100.0/` や
		// `198.51.100.0/abc` のような**打ち間違いが「全接続を信頼」に化ける**
		// （＝転送ヘッダを誰からでも受け入れる。修正前と同じ穴に戻る）。
		// 全数変異の生き残りを潰す過程で実測して見つけた。数字だけを受け付ける。
		if ( '' === $bits_raw || ! ctype_digit( $bits_raw ) ) {
			return false;
		}

		$bits = (int) $bits_raw;
		$max  = strlen( $ip_bin ) * 8;
		// ctype_digit を通しているので負にはならない。
		// ⚠️ **`/0` も受け付けない**（handoff `notes/2026-08-19-pro-to-free-7.md`）。
		// 構文としては有効だが「全アドレスに一致」＝信頼プロキシの指定としては意味が無く、
		// 一方で**接続元が誰でも「信頼済み」になる**ため `X-Real-IP` がそのまま採用される
		// （XFF は全ホップが読み飛ばされて REMOTE_ADDR に落ちるが、X-Real-IP は対象外）。
		// Pro 線が実測でこの経路のなりすましを確認している。
		if ( $bits < 1 || $bits > $max ) {
			return false;
		}

		$whole_bytes = intdiv( $bits, 8 );
		$rest_bits   = $bits % 8;

		if ( $whole_bytes > 0 && 0 !== strncmp( $ip_bin, $sub_bin, $whole_bytes ) ) {
			return false;
		}
		if ( 0 === $rest_bits ) {
			return true;
		}

		$mask = ~( ( 1 << ( 8 - $rest_bits ) ) - 1 ) & 0xFF;

		return ( ord( $ip_bin[ $whole_bytes ] ) & $mask ) === ( ord( $sub_bin[ $whole_bytes ] ) & $mask );
	}
}
