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

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


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

/**
 * WebAuthn/Passkeys 登録
 *
 * パスキーのクレデンシャル登録（attestation）のみを扱う。
 * チャレンジ生成・クレデンシャル保存は PHP 標準関数（random_bytes / hash）で自前実装する。
 *
 * REST APIエンドポイント:
 *   POST /conf-manager/v1/passkey/register/options   — 登録オプション取得
 *   POST /conf-manager/v1/passkey/register/verify     — 登録検証
 *
 * ⚠️ 認証（assertion）側は 2026-08-21 に削除した（v1.3.0）。
 * 旧実装は **WebAuthn のアサーション署名を検証していなかった**ため、
 * 認証として成立していなかった。UI からは一度も使えない状態だった。
 * ハンドラ本体を残すと `register_rest_route` を書き戻すだけで戻せてしまうので、
 * ハンドラ・トークン発行/検証・メールハッシュ検索ごと削除している。
 *
 * ⚠️ **作り直すなら署名検証が必須**（COSE 公開鍵のパース・clientDataJSON の照合・
 * authenticatorData の rpIdHash と flags・signCount の保存と単調増加の検査）。
 * 「チャレンジが存在するか」「credential_id が登録済みか」だけでは**認証にならない**
 * （どちらも秘密ではないため）。詳しい経緯は配布物には含めない
 * （handoff `notes/2026-08-21-pro-to-free-1.md` および Free の返信ノート）。
 *
 * @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' ],
		] );
	}

	/**
	 * 認証済みユーザーかチェック
	 *
	 * @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;
	}

	/**
	 * パスキー登録オプションを生成
	 *
	 * @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 );
	}

	/**
	 * クレデンシャル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'
			)
		);
	}

	/**
	 * 現在の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 );
	}

	/**
	 * 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 ) );
	}
}
