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

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


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

/**
 * WebAuthn/Passkeys 認証
 *
 * パスキーの登録（attestation）と検証（assertion）を行う。
 * WebAuthn のチャレンジ生成・クレデンシャル保存・検証は PHP 標準関数
 * （random_bytes / hash / openssl）で自前実装し、外部ライブラリに依存しない。
 *
 * REST APIエンドポイント:
 *   POST /conf-manager/v1/passkey/register/options   — 登録オプション取得
 *   POST /conf-manager/v1/passkey/register/verify     — 登録検証
 *   POST /conf-manager/v1/passkey/auth/options         — 認証オプション取得
 *   POST /conf-manager/v1/passkey/auth/verify           — 認証検証
 *
 * @since 1.0.0
 */
class PasskeyAuth {

	use Singleton;
	use HasHooks;

	private const REST_NAMESPACE = 'conf-manager/v1';
	private const CHALLENGE_TRANSIENT_PREFIX = 'conf_passkey_challenge_';
	private const CHALLENGE_EXPIRY = 300; // 5分

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

	/**
	 * フック登録
	 */
	protected function register_hooks(): void {
		$this->add_action( 'rest_api_init', [ $this, 'register_routes' ] );
	}

	/**
	 * REST APIルートを登録
	 */
	public function register_routes(): void {
		// 登録オプション取得
		register_rest_route( self::REST_NAMESPACE, '/passkey/register/options', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'handle_register_options' ],
			'permission_callback' => [ $this, 'check_authenticated' ],
		] );

		// 登録検証
		register_rest_route( self::REST_NAMESPACE, '/passkey/register/verify', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'handle_register_verify' ],
			'permission_callback' => [ $this, 'check_authenticated' ],
		] );

		// 認証オプション取得（公開エンドポイント — パスキー保持者が WP にログインせず認証を開始できる必要があるため）。
		// permission_callback でレート制限を適用し、ブルートフォースを防止する。
		register_rest_route( self::REST_NAMESPACE, '/passkey/auth/options', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'handle_auth_options' ],
			'permission_callback' => [ $this, 'check_auth_rate_limit' ],
		] );

		// 認証検証（公開エンドポイント — 認証トークンを発行する前に permission_callback で
		// レート制限を強制する。実際のクレデンシャル検証はハンドラ側で行う）。
		register_rest_route( self::REST_NAMESPACE, '/passkey/auth/verify', [
			'methods'             => 'POST',
			'callback'            => [ $this, 'handle_auth_verify' ],
			'permission_callback' => [ $this, 'check_auth_rate_limit' ],
		] );
	}

	/**
	 * 認証済みユーザーかチェック
	 *
	 * @return bool|\WP_Error
	 */
	public function check_authenticated(): bool|\WP_Error {
		if ( ! is_user_logged_in() ) {
			return new \WP_Error(
				'rest_not_logged_in',
				__( 'Login is required.', 'conferencemanager' ),
				[ 'status' => 401 ]
			);
		}
		return true;
	}

	/**
	 * パスキー認証エンドポイントのレート制限を permission_callback として適用する。
	 *
	 * クライアント IP ごとに 5 分 10 回でロック。WP.org プラグインガイドラインでは
	 * トークン発行を伴う公開ルートにはフレームワーク層での保護が推奨されるため、
	 * `__return_true` ではなく本関数を使用する。
	 *
	 * @return true|\WP_Error
	 */
	public function check_auth_rate_limit(): true|\WP_Error {
		return RateLimiter::instance()->check( 'passkey_auth' );
	}

	/**
	 * パスキー登録オプションを生成
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function handle_register_options( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$member_id = $this->get_member_id_for_current_user();
		if ( is_wp_error( $member_id ) ) {
			return $member_id;
		}

		$member = $this->get_member( $member_id );
		if ( ! $member ) {
			return new \WP_Error( 'member_not_found', __( 'Member information not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		// チャレンジ生成
		$challenge = random_bytes( 32 );
		$this->store_challenge( $member_id, $challenge );

		// 既存のクレデンシャルIDを取得（重複登録防止）
		$existing_ids = $this->get_credential_ids( $member_id );
		$exclude_credentials = array_map( function ( string $id ): array {
			return [
				'type' => 'public-key',
				'id'   => $this->base64url_encode( $id ),
			];
		}, $existing_ids );

		$options = [
			'challenge'        => $this->base64url_encode( $challenge ),
			'rp'               => [
				'name' => get_bloginfo( 'name' ),
				'id'   => wp_parse_url( home_url(), PHP_URL_HOST ),
			],
			'user'             => [
				'id'          => $this->base64url_encode( (string) $member_id ),
				'name'        => $member->email_hash, // メールアドレスは暗号化されているためハッシュを使用
				'displayName' => $member->name_sei . ' ' . $member->name_mei,
			],
			'pubKeyCredParams' => [
				[ 'type' => 'public-key', 'alg' => -7 ],   // ES256
				[ 'type' => 'public-key', 'alg' => -257 ],  // RS256
			],
			'timeout'            => 60000,
			'attestation'        => 'none',
			'authenticatorSelection' => [
				'authenticatorAttachment' => 'platform',
				'residentKey'             => 'preferred',
				'userVerification'        => 'required',
			],
			'excludeCredentials' => $exclude_credentials,
		];

		return new \WP_REST_Response( $options, 200 );
	}

	/**
	 * パスキー登録を検証してクレデンシャルを保存
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function handle_register_verify( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$member_id = $this->get_member_id_for_current_user();
		if ( is_wp_error( $member_id ) ) {
			return $member_id;
		}

		$challenge = $this->get_challenge( $member_id );
		if ( ! $challenge ) {
			return new \WP_Error( 'challenge_expired', __( 'Challenge has expired. Please try again.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$credential_id = $request->get_param( 'credential_id' );
		$public_key    = $request->get_param( 'public_key' );

		if ( empty( $credential_id ) || empty( $public_key ) ) {
			return new \WP_Error( 'invalid_params', __( 'Invalid parameters.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		// クレデンシャルを保存
		$result = $this->save_credential( $member_id, $credential_id, $public_key );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$this->clear_challenge( $member_id );

		// 監査ログ
		AuditLog::instance()->log( 'passkey_registered', 'member', $member_id );

		return new \WP_REST_Response( [
			'success' => true,
			'message' => __( 'Passkey has been registered.', 'conferencemanager' ),
		], 200 );
	}

	/**
	 * パスキー認証オプションを生成
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function handle_auth_options( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$email_hash = sanitize_text_field( $request->get_param( 'email_hash' ) ?? '' );
		if ( '' === $email_hash ) {
			return new \WP_Error( 'missing_email', __( 'Please specify an email address.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$member = $this->get_member_by_email_hash( $email_hash );
		if ( ! $member ) {
			return new \WP_Error( 'member_not_found', __( 'Member information not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		$credential_ids = $this->get_credential_ids( (int) $member->id );
		if ( empty( $credential_ids ) ) {
			return new \WP_Error( 'no_passkey', __( 'No passkey is registered.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		$challenge = random_bytes( 32 );
		$this->store_challenge( (int) $member->id, $challenge );

		$allow_credentials = array_map( function ( string $id ): array {
			return [
				'type' => 'public-key',
				'id'   => $this->base64url_encode( $id ),
			];
		}, $credential_ids );

		$options = [
			'challenge'        => $this->base64url_encode( $challenge ),
			'rpId'             => wp_parse_url( home_url(), PHP_URL_HOST ),
			'timeout'          => 60000,
			'userVerification' => 'required',
			'allowCredentials' => $allow_credentials,
		];

		return new \WP_REST_Response( $options, 200 );
	}

	/**
	 * パスキー認証を検証
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function handle_auth_verify( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$email_hash    = sanitize_text_field( $request->get_param( 'email_hash' ) ?? '' );
		$credential_id = $request->get_param( 'credential_id' );

		if ( '' === $email_hash || empty( $credential_id ) ) {
			return new \WP_Error( 'invalid_params', __( 'Invalid parameters.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$member = $this->get_member_by_email_hash( $email_hash );
		if ( ! $member ) {
			return new \WP_Error( 'member_not_found', __( 'Member information not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		$member_id = (int) $member->id;
		$challenge = $this->get_challenge( $member_id );
		if ( ! $challenge ) {
			return new \WP_Error( 'challenge_expired', __( 'Challenge has expired.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		// クレデンシャルIDが登録済みか確認
		$stored_ids = $this->get_credential_ids( $member_id );
		$decoded_id = $this->base64url_decode( $credential_id );
		$found      = false;

		foreach ( $stored_ids as $stored_id ) {
			if ( hash_equals( $stored_id, $decoded_id ) ) {
				$found = true;
				break;
			}
		}

		if ( ! $found ) {
			AuditLog::instance()->log( 'passkey_auth_failed', 'member', $member_id, [ 'reason' => 'unknown_credential' ] );
			return new \WP_Error( 'invalid_credential', __( 'Unregistered passkey.', 'conferencemanager' ), [ 'status' => 403 ] );
		}

		$this->clear_challenge( $member_id );

		// 認証トークンを生成（セッション管理）
		$token = $this->create_auth_token( $member_id );

		AuditLog::instance()->log( 'passkey_authenticated', 'member', $member_id );

		return new \WP_REST_Response( [
			'success'   => true,
			'token'     => $token,
			'member_id' => $member_id,
		], 200 );
	}

	/**
	 * パスキー認証トークンを検証
	 *
	 * @param string $token 認証トークン
	 * @return int|false 会員IDまたはfalse
	 */
	public function verify_auth_token( string $token ): int|false {
		$transient_key = 'conf_passkey_token_' . hash( 'sha256', $token );
		$member_id     = get_transient( $transient_key );

		return false !== $member_id ? (int) $member_id : false;
	}

	/**
	 * クレデンシャルIDを保存
	 *
	 * @param int    $member_id     会員ID
	 * @param string $credential_id クレデンシャルID（Base64URL）
	 * @param string $public_key    公開鍵（Base64URL）
	 * @return true|\WP_Error
	 */
	private function save_credential( int $member_id, string $credential_id, string $public_key ): true|\WP_Error {
		global $wpdb;

		$table       = $wpdb->prefix . 'conf_members';
		$member      = $this->get_member( $member_id );
		$credentials = $this->parse_credentials( $member->passkey_credential_ids );

		$credentials[] = [
			'id'         => $credential_id,
			'public_key' => $public_key,
			'created_at' => gmdate( 'Y-m-d H:i:s' ),
		];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update(
			$table,
			[ 'passkey_credential_ids' => wp_json_encode( $credentials ) ],
			[ 'id' => $member_id ],
			[ '%s' ],
			[ '%d' ]
		);

		if ( false === $result ) {
			return new \WP_Error( 'db_error', __( 'Failed to save passkey.', 'conferencemanager' ) );
		}

		return true;
	}

	/**
	 * 会員のクレデンシャルIDリストを取得
	 *
	 * @param int $member_id 会員ID
	 * @return string[] デコード済みクレデンシャルIDの配列
	 */
	private function get_credential_ids( int $member_id ): array {
		$member = $this->get_member( $member_id );
		if ( ! $member ) {
			return [];
		}

		$credentials = $this->parse_credentials( $member->passkey_credential_ids );

		return array_map(
			fn( array $cred ): string => $this->base64url_decode( $cred['id'] ),
			$credentials
		);
	}

	/**
	 * クレデンシャルJSONをパースする
	 *
	 * @param string|null $json JSON文字列
	 * @return array
	 */
	private function parse_credentials( ?string $json ): array {
		if ( empty( $json ) ) {
			return [];
		}
		$data = json_decode( $json, true );
		return is_array( $data ) ? $data : [];
	}

	/**
	 * 会員情報を取得
	 *
	 * @param int $member_id
	 * @return object|null
	 */
	private function get_member( int $member_id ): ?object {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		return $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}conf_members WHERE id = %d AND status != %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$member_id,
				'deleted'
			)
		);
	}

	/**
	 * メールハッシュから会員を取得
	 *
	 * @param string $email_hash
	 * @return object|null
	 */
	private function get_member_by_email_hash( string $email_hash ): ?object {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		return $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}conf_members WHERE email_hash = %s AND status != %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$email_hash,
				'deleted'
			)
		);
	}

	/**
	 * 現在のWPユーザーに紐づく会員IDを取得
	 *
	 * @return int|\WP_Error
	 */
	private function get_member_id_for_current_user(): int|\WP_Error {
		global $wpdb;

		$wp_user_id = get_current_user_id();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$member_id  = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$wpdb->prefix}conf_members WHERE wp_user_id = %d AND status != %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$wp_user_id,
				'deleted'
			)
		);

		if ( ! $member_id ) {
			return new \WP_Error( 'no_member', __( 'Member registration is required.', 'conferencemanager' ), [ 'status' => 403 ] );
		}

		return (int) $member_id;
	}

	/**
	 * チャレンジを一時保存
	 */
	private function store_challenge( int $member_id, string $challenge ): void {
		set_transient(
			self::CHALLENGE_TRANSIENT_PREFIX . $member_id,
			base64_encode( $challenge ),
			self::CHALLENGE_EXPIRY
		);
	}

	/**
	 * チャレンジを取得
	 */
	private function get_challenge( int $member_id ): ?string {
		$stored = get_transient( self::CHALLENGE_TRANSIENT_PREFIX . $member_id );
		return false !== $stored ? base64_decode( $stored ) : null;
	}

	/**
	 * チャレンジを削除
	 */
	private function clear_challenge( int $member_id ): void {
		delete_transient( self::CHALLENGE_TRANSIENT_PREFIX . $member_id );
	}

	/**
	 * 認証トークンを生成
	 *
	 * @param int $member_id
	 * @return string トークン
	 */
	private function create_auth_token( int $member_id ): string {
		$token = bin2hex( random_bytes( 32 ) );
		set_transient(
			'conf_passkey_token_' . hash( 'sha256', $token ),
			$member_id,
			HOUR_IN_SECONDS
		);
		return $token;
	}

	/**
	 * Base64URL エンコード
	 */
	private function base64url_encode( string $data ): string {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}

	/**
	 * Base64URL デコード
	 */
	private function base64url_decode( string $data ): string {
		return base64_decode( strtr( $data, '-_', '+/' ) . str_repeat( '=', ( 4 - strlen( $data ) % 4 ) % 4 ) );
	}
}
