<?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;

	/**
	 * レート制限ルール（アクション => [上限回数, ウィンドウ秒数, ロックアウト秒数]）
	 */
	private const RULES = [
		'login'             => [ 'limit' => 10, 'window' => 300,  'lockout' => 600 ],   // 10回/5分、超過で10分ロック
		'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時間ロック
		'password_reset'    => [ 'limit' => 3,  'window' => 3600, 'lockout' => 3600 ],  // 3回/1時間、超過で1時間ロック
		'self_register_otp' => [ 'limit' => 3,  'window' => 3600, 'lockout' => 3600 ],  // 3回/1時間、超過で1時間ロック
		// メールキーのレート制限（IP非依存、分散攻撃対策）
		'otp_per_email'     => [ 'limit' => 5,  'window' => 3600, 'lockout' => 3600 ],  // 5回/1時間/メール
		'lookup_per_email'  => [ 'limit' => 10, 'window' => 3600, 'lockout' => 3600 ],  // 10回/1時間/メール
	];

	/**
	 * 初期化
	 */
	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' ] );
	}

	/**
	 * レート制限をチェックする
	 *
	 * 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'] );

			// 監査ログに記録
			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'];
		$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'] ) {
			$lockout_key = $this->get_lockout_key( 'login', $identifier );
			set_transient( $lockout_key, time() + $rule['lockout'], $rule['lockout'] );

			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'];
		$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アドレスを取得
	 *
	 * @return string
	 */
	public function get_client_ip(): string {
		$headers = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ];

		foreach ( $headers as $header ) {
			if ( ! isset( $_SERVER[ $header ] ) ) {
				continue;
			}
			$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Sanitized on this line; dynamic key not recognized by PHPCS.
			if ( '' !== $ip ) {
				$ip = trim( explode( ',', $ip )[0] );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}

		return '0.0.0.0';
	}
}
