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

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


use ConfManager\Traits\Singleton;
use ConfManager\Traits\HasHooks;
use ConfManager\Events\EventManager;
use ConfManager\Events\SessionManager;
use ConfManager\Members\MemberManager;
use ConfManager\Members\SelfRegistration;
use ConfManager\Registration\RegistrationManager;
use ConfManager\Registration\GroupRegistration;
use ConfManager\Payment\BankTransfer;
use ConfManager\Payment\PaymentManager;
use ConfManager\Payment\InvoiceGenerator;
use ConfManager\Payment\ReceiptGenerator;
use ConfManager\Payment\PaymentDocuments;
use ConfManager\Security\Encryption;
use ConfManager\Security\RateLimiter;
use ConfManager\Security\BotGuard;
use ConfManager\Security\AuditLog;

/**
 * 公開REST APIエンドポイント
 *
 * 参加者向け（管理者権限不要）のエンドポイント。
 * nonce 認証またはパスキー認証で保護。
 *
 * @since 1.0.0
 */
class PublicApi {

	use Singleton;
	use HasHooks;

	private const NAMESPACE = 'conf-manager/v1';

	protected function init(): void {
		$this->register_hooks();
	}

	protected function register_hooks(): void {
		$this->add_action( 'rest_api_init', [ $this, 'register_routes' ] );
	}

	/**
	 * 公開POSTエンドポイント用のCSRFトークン検証
	 *
	 * X-Conf-CSRF-Token ヘッダーの wp_nonce を検証し、
	 * クロスオリジンからの不正リクエストを防止する。
	 *
	 * @param \WP_REST_Request $request リクエスト
	 * @return true|\WP_Error 検証成功ならtrue、失敗ならWP_Error
	 */
	private function verify_csrf( \WP_REST_Request $request ): true|\WP_Error {
		$token = $request->get_header( 'X-Conf-CSRF-Token' );
		if ( empty( $token ) || ! wp_verify_nonce( $token, 'conf_manager_public_csrf' ) ) {
			return new \WP_Error(
				'csrf_failed',
				__( 'Security verification failed. Please reload the page.', 'conferencemanager' ),
				[ 'status' => 403 ]
			);
		}
		return true;
	}

	/**
	 * トークン認証が必須な公開エンドポイント用の permission_callback
	 *
	 * CSRF トークンを検証し、リクエストボディの auth_token（OTP セッション or
	 * パスキートークン）が有効な会員／スタッフセッションに解決できることを確認する。
	 * 認可をルート登録時点（permission_callback）で行うことで、WordPress に
	 * 「このエンドポイントはアクセス制御されている」ことを明示する。
	 * ハンドラ側でも auth_token の再解決を行っており（多層防御）、本メソッドは
	 * その前段ゲートとして機能する。
	 *
	 * @param \WP_REST_Request $request リクエスト
	 * @return true|\WP_Error 有効なトークンなら true、それ以外は WP_Error
	 */
	public function permission_token_required( \WP_REST_Request $request ): true|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$auth_token = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		if ( '' === $auth_token ) {
			return new \WP_Error(
				'unauthorized',
				__( 'Authentication required.', 'conferencemanager' ),
				[ 'status' => 401 ]
			);
		}

		if ( null === $this->resolve_session_from_token( $auth_token ) ) {
			return new \WP_Error(
				'invalid_token',
				__( 'Your session has expired. Please log in again.', 'conferencemanager' ),
				[ 'status' => 401 ]
			);
		}

		return true;
	}

	public function register_routes(): void {
		// 公開イベント一覧（認証不要）
		register_rest_route( self::NAMESPACE, '/public/events', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ $this, 'get_public_events' ],
				// Public endpoint: intentionally unauthenticated. Returns only published events.
				'permission_callback' => '__return_true',
			],
		] );

		// 公開イベント詳細 + セッション（認証不要）
		register_rest_route( self::NAMESPACE, '/public/events/(?P<id>[\d]+)', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ $this, 'get_public_event' ],
				// Public endpoint: intentionally unauthenticated. Handler returns 404 for non-published events.
				'permission_callback' => '__return_true',
			],
		] );

		// 料金計算プレビュー（認証不要）
		register_rest_route( self::NAMESPACE, '/public/events/(?P<id>[\d]+)/fee', [
			[
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => [ $this, 'calculate_fee' ],
				// Public endpoint: intentionally unauthenticated. Handler returns 404 for non-published events.
				'permission_callback' => '__return_true',
			],
		] );

		// 会員検索（メールアドレスで）
		// verify_csrf + BotGuard + RateLimiter inside the handler enforce access control.
		register_rest_route( self::NAMESPACE, '/public/members/lookup', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'lookup_member' ],
				// Public endpoint: CSRF + BotGuard + rate limit enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// 団体申込（認証不要）
		register_rest_route( self::NAMESPACE, '/public/group-registrations', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'create_group_registration' ],
				// Public endpoint: CSRF + BotGuard + rate limit enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// 参加申込
		register_rest_route( self::NAMESPACE, '/public/registrations', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'create_registration' ],
				// Public endpoint: CSRF + BotGuard + rate limit enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// 自分の申込一覧（認証トークン必須 — ハンドラでトークン検証）
		register_rest_route( self::NAMESPACE, '/public/registrations/mine', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'get_my_registrations' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );

		// マイページ（認証トークン必須 — ハンドラでトークン検証）
		register_rest_route( self::NAMESPACE, '/public/mypage', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'get_mypage_data' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );

		// マイページ: 領収書の宛名を更新して PDF を再発行（認証トークン必須）
		register_rest_route( self::NAMESPACE, '/public/mypage/receipt-addressee', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'update_receipt_addressee' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );

		// マイページ: 請求書・領収書を取得する（認証トークン必須）
		//
		// 🔴 公開 URL を作らず、**所有権を検証してから base64 で返す**。
		//    帳票には氏名・所属・金額が載るので、推測可能な URL に置かない。
		register_rest_route( self::NAMESPACE, '/public/mypage/document', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'get_mypage_document' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );

		// 自己登録: OTP送信
		register_rest_route( self::NAMESPACE, '/public/self-register/send-otp', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'self_register_send_otp' ],
				// Public endpoint: CSRF + BotGuard enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// 自己登録: OTP検証
		register_rest_route( self::NAMESPACE, '/public/self-register/verify-otp', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'self_register_verify_otp' ],
				// Public endpoint: CSRF + BotGuard enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// 自己登録: プロフィール入力して完了
		register_rest_route( self::NAMESPACE, '/public/self-register/complete', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'self_register_complete' ],
				// Public endpoint: CSRF + BotGuard enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// マイページ団体スタッフ一覧
		register_rest_route( self::NAMESPACE, '/public/mypage/group-staff', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'get_group_staff' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );

		// マイページOTP送信
		register_rest_route( self::NAMESPACE, '/public/mypage/send-otp', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'send_otp' ],
				// Public endpoint: CSRF + BotGuard + rate limit enforced in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// マイページOTP検証
		register_rest_route( self::NAMESPACE, '/public/mypage/verify-otp', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'verify_otp' ],
				// Public endpoint: CSRF + rate limit enforced; OTP validation performed in handler.
				'permission_callback' => '__return_true',
			],
		] );

		// マイページログアウト（トークン無効化）
		register_rest_route( self::NAMESPACE, '/public/mypage/logout', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'logout' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );

		// マイページ メール配信設定
		register_rest_route( self::NAMESPACE, '/public/mypage/email-preferences', [
			[
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'update_email_preferences' ],
				// Token-based access: CSRF + auth_token validated in permission_callback.
				'permission_callback' => [ $this, 'permission_token_required' ],
			],
		] );
	}

	/**
	 * 領収書の宛名を更新して PDF を再発行する（認証トークン必須）
	 *
	 * 参加者がマイページから自分の領収書の宛名・部署を編集する。所有権を
	 * 領収書→決済→申込の会員で検証し（IDOR 防止）、receipt_no を変えずに
	 * 既存 PDF を上書き再生成する。
	 */
	public function update_receipt_addressee( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$rate_check = RateLimiter::instance()->check( 'api_request' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$auth_token = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		$member_id  = $this->resolve_member_from_token( $auth_token );
		if ( null === $member_id ) {
			return new \WP_Error( 'invalid_token', __( 'Authentication token is invalid or expired.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$receipt_id = absint( $request->get_param( 'receipt_id' ) );
		if ( $receipt_id <= 0 ) {
			return new \WP_Error( 'missing_receipt_id', __( 'Receipt ID is not specified.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$receipt = ReceiptGenerator::instance()->get( $receipt_id );
		if ( null === $receipt ) {
			return new \WP_Error( 'receipt_not_found', __( 'Receipt not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		// 所有権検証: 領収書 → 決済 → 申込 の会員が auth_token の会員と一致（IDOR 防止）。
		$payment = PaymentManager::instance()->get( (int) $receipt['payment_id'] );
		if ( null === $payment ) {
			return new \WP_Error( 'payment_not_found', __( 'Payment record not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}
		$registration = RegistrationManager::instance()->get( (int) $payment['registration_id'] );
		if ( null === $registration || (int) $registration['member_id'] !== $member_id ) {
			return new \WP_Error( 'forbidden', __( 'You do not have permission to edit this receipt.', 'conferencemanager' ), [ 'status' => 403 ] );
		}

		$addressee_name = sanitize_text_field( $request->get_param( 'addressee_name' ) ?? '' );
		$addressee_dept = sanitize_text_field( $request->get_param( 'addressee_dept' ) ?? '' );
		if ( '' === $addressee_name ) {
			return new \WP_Error( 'missing_addressee', __( 'Addressee is required.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$result = ReceiptGenerator::instance()->update_addressee( $receipt_id, $addressee_name, $addressee_dept );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// ⭐ URL は返さない（帳票は要求のたびに描画するので、次のダウンロードに反映される）。
		return rest_ensure_response( [
			'success'        => true,
			'addressee_name' => $addressee_name,
			'addressee_dept' => $addressee_dept,
		] );
	}

	/**
	 * POST /public/mypage/document — 自分の請求書・領収書を取得する
	 *
	 * 返すのは { filename, pdf_b64, document_no }（管理画面の発行と同じ形）。
	 *
	 * 🔴 所有権を必ず検証する: 申込の会員が auth_token の会員と一致すること。
	 *    申込 ID だけで引けるようにすると、他人の連番を投げるだけで読めてしまう。
	 */
	public function get_mypage_document( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$rate_check = RateLimiter::instance()->check( 'api_request' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$member_id = $this->resolve_member_from_token( sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' ) );
		if ( null === $member_id ) {
			return new \WP_Error( 'invalid_token', __( 'Authentication token is invalid or expired.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$type = sanitize_text_field( $request->get_param( 'type' ) ?? '' );
		if ( ! in_array( $type, [ PaymentDocuments::TYPE_INVOICE, PaymentDocuments::TYPE_RECEIPT ], true ) ) {
			return new \WP_Error( 'invalid_document_type', __( 'Invalid document type.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$registration_id = absint( $request->get_param( 'registration_id' ) );
		$registration    = $registration_id > 0 ? RegistrationManager::instance()->get( $registration_id ) : null;

		// 🔴 見つからない場合と他人の申込は**同じ 403** を返す（申込 ID の存在を漏らさない）。
		if ( null === $registration || (int) $registration['member_id'] !== $member_id ) {
			return new \WP_Error( 'forbidden', __( 'You do not have permission to view this document.', 'conferencemanager' ), [ 'status' => 403 ] );
		}

		$result = PaymentDocuments::instance()->issue_for_registration( $registration_id, $type, PaymentDocuments::SOURCE_MYPAGE );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

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

	/**
	 * マイページデータ（認証トークン必須）
	 *
	 * OTP認証またはパスキー認証で取得したauth_tokenが必須。
	 * パスキー認証済みの場合は会員情報の編集も可能。
	 */
	public function get_mypage_data( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$rate_check = RateLimiter::instance()->check( 'api_request' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$auth_token = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		if ( '' === $auth_token ) {
			return new \WP_Error( 'unauthorized', __( 'Authentication required.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		// OTPセッショントークンから会員IDを取得
		$member_id = null;

		// OTPセッショントークンを検索
		$otp_session = get_transient( 'conf_otp_session_' . $auth_token );
		if ( false !== $otp_session ) {
			if ( is_array( $otp_session ) && 'member' === ( $otp_session['type'] ?? '' ) ) {
				$member_id = (int) $otp_session['member_id'];
			} elseif ( ! is_array( $otp_session ) ) {
				// 旧形式（int）
				$member_id = (int) $otp_session;
			}
		}

		if ( null === $member_id ) {
			return new \WP_Error( 'invalid_token', __( 'Authentication token is invalid or expired.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$member = MemberManager::instance()->get( $member_id );
		if ( null === $member ) {
			return new \WP_Error( 'not_found', __( 'Member not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		$registrations = RegistrationManager::instance()->get_by_member( $member_id );

		$reg_items = array_map( function ( $reg ) use ( $member ) {
			$event = EventManager::instance()->get( (int) $reg['event_id'] );

			// 帳票の**有無だけ**を返す。
			//
			// 🔴 **URL は返さない。** 以前は uploads 配下に置いた PDF の公開 URL を
			//    そのまま渡していたが、ファイル名が `INV-202608-00001.pdf` の連番なので
			//    **総当たりで第三者に読まれる**（申込・氏名・所属・金額が載る）。
			//    いまはボタンを押した時点で POST /public/mypage/document を叩き、
			//    所有権を検証してから base64 で返す。
			$payments = PaymentManager::instance()->get_by_registration( (int) $reg['id'] );
			$payment  = $payments[0] ?? null;
			$invoice  = null;
			$receipt  = null;

			if ( $payment ) {
				$invoice = InvoiceGenerator::instance()->get_by_payment( (int) $payment['id'] );
				$receipt = ReceiptGenerator::instance()->get_by_payment( (int) $payment['id'] );
			}

			// マイページで宛名編集できるよう領収書メタを付与。
			$receipt_meta = $receipt ? [
				'id'             => (int) $receipt['id'],
				'addressee_name' => $receipt['addressee_name'] ?? '',
				'addressee_dept' => $receipt['addressee_dept'] ?? '',
			] : null;

			// 団体スタッフ数
			$staff_count = 0;
			if ( ! empty( $reg['group_id'] ) && ! empty( $reg['is_representative'] ) ) {
				$staff_count = GroupRegistration::instance()->count_staff( (int) $reg['id'] );
			}

			return [
				'id'                 => (int) $reg['id'],
				'registration_no'    => $reg['registration_no'],
				'event_title'        => $event ? $event['title'] : '',
				'event_date'         => $event ? $event['date_start'] : '',
				'participation_type' => $reg['participation_type'],
				// 参加区分（イベント編集画面の料金表に登録した会員種別。独自区分も含む）。
				// 🔴 **サーバ側でだけ装飾する。** JSX 側でも通すと前置が 2 回入る
				//    （装飾はキーで判定するので、ラベルを見ても二重前置は止まらない）。
				// 🔴 DB の焼き付け（fee_type_label）は書き換えない。返す直前にだけ通す。
				// 📌 古い申込は fee_type / fee_type_label が空。空文字のまま返し、
				//    画面側で行ごと出さない（帳票と同じ扱い）。
				'fee_type_label'     => \ConfManager\Members\MemberTypeLabel::decorate_registration(
					(string) ( $reg['fee_type'] ?? '' ),
					(string) ( $reg['fee_type_label'] ?? '' )
				),
				'amount'             => (int) $reg['amount'],
				'tax'                => (int) $reg['tax'],
				'status'             => $reg['status'],
				'group_id'           => $reg['group_id'] ?? null,
				'is_representative'  => (bool) ( $reg['is_representative'] ?? false ),
				'staff_count'        => $staff_count,
				'has_invoice'        => null !== $invoice,
				'has_receipt'        => null !== $receipt,
				'receipt'            => $receipt_meta,
				'registered_name'    => trim( ( $member['name_sei'] ?? '' ) . ' ' . ( $member['name_mei'] ?? '' ) ),
			];
		}, $registrations );

		// トークンローテーション（OTPセッション）
		$new_auth_token = null;
		if ( false !== $otp_session && is_array( $otp_session ) ) {
			$new_auth_token = $this->rotate_token( $auth_token, $otp_session );
		}

		return new \WP_REST_Response( [
			'member' => [
				'id'            => $member_id,
				'name_sei'      => $member['name_sei'],
				'name_mei'      => $member['name_mei'],
				'member_type'   => $member['member_type'],
				'org_name'      => $member['org_name'] ?? '',
				'org_dept'      => $member['org_dept'] ?? '',
				'email_opt_out' => (bool) ( $member['email_opt_out'] ?? false ),
			],
			'registrations'    => $reg_items,
			'is_authenticated' => true,
			// パスキー認証は削除済み（署名を検証していなかった）。応答キーは互換のため残し常に false。
			'can_edit'         => false,
			'new_auth_token'   => $new_auth_token,
		], 200 );
	}

	/**
	 * 公開イベント一覧
	 */
	public function get_public_events(): \WP_REST_Response {
		$events = EventManager::instance()->get_published();

		$items = array_map( function ( $event ) {
			$em = EventManager::instance();
			return [
				'id'                    => (int) $event['id'],
				'title'                 => $event['title'],
				'date_start'            => $event['date_start'],
				'date_end'              => $event['date_end'],
				'venue'                 => $event['venue'] ?? '',
				'reg_start'             => $event['reg_start'] ?? '',
				'reg_end'               => $event['reg_end'] ?? '',
				'fee_rules'             => $event['fee_rules'] ?? [],
				'fee_rules_day'         => $event['fee_rules_day'] ?? [],
				'advance_fee_deadline'  => $event['advance_fee_deadline'] ?? null,
				'advance_fee_notice'    => $event['advance_fee_notice'] ?? null,
				'is_advance_period'     => $em->is_advance_period( $event ),
				'current_fee_rules'     => $em->resolve_fee_rules( $event ),
				'tax_rate'              => (float) ( $event['tax_rate'] ?? 10.0 ),
			];
		}, $events );

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

	/**
	 * 公開イベント詳細 + セッション
	 */
	public function get_public_event( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$event = EventManager::instance()->get( (int) $request['id'] );
		if ( null === $event || 'published' !== $event['status'] ) {
			return new \WP_Error( 'not_found', __( 'Event not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		$sessions = SessionManager::instance()->get_by_event( (int) $event['id'] );

		$em = EventManager::instance();

		return new \WP_REST_Response( [
			'id'                    => (int) $event['id'],
			'title'                 => $event['title'],
			'description'           => $event['description'] ?? '',
			'date_start'            => $event['date_start'],
			'date_end'              => $event['date_end'],
			'venue'                 => $event['venue'] ?? '',
			'venue_address'         => $event['venue_address'] ?? '',
			'capacity'              => $event['capacity'],
			'reg_start'             => $event['reg_start'] ?? '',
			'reg_end'               => $event['reg_end'] ?? '',
			'fee_rules'             => $event['fee_rules'] ?? [],
			'fee_rules_day'         => $event['fee_rules_day'] ?? [],
			'advance_fee_deadline'  => $event['advance_fee_deadline'] ?? null,
			'advance_fee_notice'    => $event['advance_fee_notice'] ?? null,
			'is_advance_period'     => $em->is_advance_period( $event ),
			'current_fee_rules'     => $em->resolve_fee_rules( $event ),
			'tax_rate'              => (float) ( $event['tax_rate'] ?? 10.0 ),
			'sessions'      => array_map( function ( $s ) {
				return [
					'id'           => (int) $s['id'],
					'title'        => $s['title'],
					'session_type' => $s['session_type'],
					'date_start'   => $s['date_start'],
					'date_end'     => $s['date_end'],
					'fee'          => (int) $s['fee'],
					'capacity'     => $s['capacity'],
				];
			}, $sessions ),
		], 200 );
	}

	/**
	 * 料金計算プレビュー
	 */
	public function calculate_fee( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$event = EventManager::instance()->get( (int) $request['id'] );
		// 公開（published）イベントのみ料金を計算する。下書き・終了・中止イベントの
		// 料金情報を未認証で露出しないよう get_public_event と同じ条件で 404 を返す。
		if ( null === $event || 'published' !== $event['status'] ) {
			return new \WP_Error( 'not_found', __( 'Event not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		$member_type       = sanitize_text_field( $request->get_param( 'member_type' ) ?? 'regular' );
		$participation_type = sanitize_text_field( $request->get_param( 'participation_type' ) ?? 'onsite' );
		$session_ids        = $request->get_param( 'sessions' ) ?? [];

		$resolved = EventManager::instance()->resolve_fee_rules( $event );
		$base_fee = (int) ( $resolved[ $member_type ][ $participation_type ] ?? 0 );

		$session_fee = 0;
		foreach ( (array) $session_ids as $sid ) {
			$session = SessionManager::instance()->get( absint( $sid ) );
			if ( $session && (int) $session['event_id'] === (int) $event['id'] ) {
				$session_fee += (int) $session['fee'];
			}
		}

		$amount   = $base_fee + $session_fee;
		$tax_rate = (float) ( $event['tax_rate'] ?? 10.0 );
		$tax      = $tax_rate > 0 ? (int) floor( $amount * $tax_rate / 100 ) : 0;

		return new \WP_REST_Response( [
			'base_fee'    => $base_fee,
			'session_fee' => $session_fee,
			'amount'      => $amount,
			'tax'         => $tax,
			'tax_rate'    => $tax_rate,
			'total'       => $amount + $tax,
		], 200 );
	}

	/**
	 * メールアドレスで会員を検索
	 *
	 * 未認証の公開エンドポイントであるため、個人情報（氏名）は一切返さない。
	 * 在籍有無（found）と、後続の申込・料金判定に必要な非識別メタ（id / member_type /
	 * email_hash）のみを返す。氏名は本人確認後にサーバ側で保持済みの値を使用する。
	 * email_hash は申込時の所有者検証に必要。
	 */
	public function lookup_member( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$email = sanitize_email( $request->get_param( 'email' ) ?? '' );
		if ( empty( $email ) || ! is_email( $email ) ) {
			return new \WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		// IP + メール複合レート制限
		$email_hash = Encryption::hash_email( $email );
		$rate_check = RateLimiter::instance()->check_composite( 'member_lookup', 'lookup_per_email', $email_hash );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$member = MemberManager::instance()->get_by_email( $email );
		if ( null === $member ) {
			return new \WP_REST_Response( [ 'found' => false ], 200 );
		}

		// 個人情報（氏名）は返さない。在籍有無と非識別メタのみ。
		return new \WP_REST_Response( [
			'found'       => true,
			'id'          => (int) $member['id'],
			'member_type' => $member['member_type'],
			'email_hash'  => $member['email_hash'],
		], 200 );
	}

	/**
	 * 参加申込を作成
	 */
	public function create_registration( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$rate_check = RateLimiter::instance()->check( 'registration' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$event_id           = absint( $request->get_param( 'event_id' ) );
		$member_id          = absint( $request->get_param( 'member_id' ) );
		$participation_type = sanitize_text_field( $request->get_param( 'participation_type' ) ?? 'onsite' );
		$payment_method     = sanitize_text_field( $request->get_param( 'payment_method' ) ?? 'bank' );
		$session_options    = $request->get_param( 'session_options' ) ?? [];
		$fee_type           = sanitize_text_field( $request->get_param( 'fee_type' ) ?? '' );
		$transfer_note      = sanitize_text_field( $request->get_param( 'transfer_note' ) ?? '' );

		// member_id の所有者検証: email_hash が一致するか確認
		$email_hash = sanitize_text_field( $request->get_param( 'email_hash' ) ?? '' );
		if ( empty( $email_hash ) ) {
			return new \WP_Error(
				'missing_email_hash',
				__( 'Missing authentication credentials.', 'conferencemanager' ),
				[ 'status' => 400 ]
			);
		}

		$member = MemberManager::instance()->get( $member_id );
		if ( null === $member || ! hash_equals( $member['email_hash'], $email_hash ) ) {
			return new \WP_Error(
				'invalid_member',
				__( 'Failed to verify member information.', 'conferencemanager' ),
				[ 'status' => 403 ]
			);
		}

		$reg_id = RegistrationManager::instance()->create( [
			'event_id'           => $event_id,
			'member_id'          => $member_id,
			'participation_type' => $participation_type,
			'payment_method'     => $payment_method,
			'session_options'    => $session_options,
			'fee_type'           => $fee_type,
			'transfer_note'      => $transfer_note,
		] );

		if ( is_wp_error( $reg_id ) ) {
			$reg_id->add_data( [ 'status' => 400 ] );
			return $reg_id;
		}

		// 決済レコード作成
		$payment_id    = 0;
		$client_secret = '';

		// Free (total of ¥0) registrations need no payment. create() already set
		// them to confirmed, so skip the payment record (PaymentManager rejects amount <= 0).
		$created_reg = RegistrationManager::instance()->get( $reg_id );
		$reg_is_free = null !== $created_reg
			&& ( (int) $created_reg['amount'] + (int) $created_reg['tax'] ) <= 0;

		if ( $reg_is_free ) {
			$payment_id = 0;
		} elseif ( 'bank' === $payment_method ) {
			$payment_result = BankTransfer::instance()->create_payment( $reg_id );
			if ( ! is_wp_error( $payment_result ) ) {
				$payment_id = (int) $payment_result;
			}
		}
		// ⚠️ v1.4.0 でカード決済（Stripe）は Pro 版の機能になった。新規の申込で
		// payment_method='stripe' は RegistrationManager::validate() が先に弾くので、
		// ここに落ちてくることはない（$payment_id は 0 のまま）。

		/**
		 * 参加申込作成後に発火
		 *
		 * @since 1.2.0
		 * @param int $reg_id     申込ID
		 * @param int $payment_id 決済ID（0の場合あり）
		 */
		do_action( 'conf_manager_registration_created', $reg_id, $payment_id );

		$registration = RegistrationManager::instance()->get( $reg_id );

		$response_data = [
			'registration_id' => $reg_id,
			'registration_no' => $registration['registration_no'],
			'amount'          => (int) $registration['amount'],
			'tax'             => (int) $registration['tax'],
			'status'          => $registration['status'],
		];

		if ( '' !== $client_secret ) {
			$response_data['client_secret'] = $client_secret;
		}

		return new \WP_REST_Response( $response_data, 201 );
	}

	/**
	 * 自分の申込一覧（認証トークン必須）
	 */
	public function get_my_registrations( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$rate_check = RateLimiter::instance()->check( 'api_request' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$auth_token = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		if ( '' === $auth_token ) {
			return new \WP_Error( 'unauthorized', __( 'Authentication required.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$session = $this->resolve_session_from_token( $auth_token );
		if ( null === $session ) {
			return new \WP_Error( 'invalid_token', __( 'Authentication token is invalid or expired.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		if ( 'staff' === $session['type'] ) {
			return $this->get_staff_registrations( (int) $session['staff_id'] );
		}

		$registrations = RegistrationManager::instance()->get_by_member( (int) $session['member_id'] );

		$items = array_map( function ( $reg ) {
			$event = EventManager::instance()->get( (int) $reg['event_id'] );
			return [
				'registration_no'    => $reg['registration_no'],
				'event_title'        => $event ? $event['title'] : '',
				'event_date'         => $event ? $event['date_start'] : '',
				'participation_type' => $reg['participation_type'],
				'amount'             => (int) $reg['amount'],
				'tax'                => (int) $reg['tax'],
				'status'             => $reg['status'],
				'registered_at'      => $reg['registered_at'],
			];
		}, $registrations );

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

	/**
	 * グループスタッフの申込情報を取得
	 */
	private function get_staff_registrations( int $staff_id ): \WP_REST_Response {
		$staff = GroupRegistration::instance()->get_staff( $staff_id );
		if ( null === $staff ) {
			return new \WP_REST_Response( [], 200 );
		}

		$registration = RegistrationManager::instance()->get( (int) $staff['registration_id'] );
		if ( null === $registration ) {
			return new \WP_REST_Response( [], 200 );
		}

		$event = EventManager::instance()->get( (int) $registration['event_id'] );

		$items = [
			[
				'registration_no'    => $registration['registration_no'],
				'event_title'        => $event ? $event['title'] : '',
				'event_date'         => $event ? $event['date_start'] : '',
				'participation_type' => $staff['participation_type'] ?? $registration['participation_type'],
				'amount'             => 0,
				'tax'                => 0,
				'status'             => $registration['status'],
				'registered_at'      => $staff['created_at'],
				'is_staff'           => true,
			],
		];

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

	/**
	 * OTP認証コードをメール送信
	 */
	public function send_otp( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$email = sanitize_email( $request->get_param( 'email' ) ?? '' );
		if ( empty( $email ) || ! is_email( $email ) ) {
			return new \WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		// IP + メール複合レート制限
		$email_hash_for_rate = Encryption::hash_email( $email );
		$rate_check = RateLimiter::instance()->check_composite( 'mypage_otp', 'otp_per_email', $email_hash_for_rate );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		// 会員またはグループスタッフを検索
		$member = MemberManager::instance()->get_by_email( $email );
		$staff  = null;

		if ( null === $member ) {
			$staff = GroupRegistration::instance()->get_staff_by_email( $email );
		}

		if ( null === $member && null === $staff ) {
			// 存在しなくても同じレスポンスを返す（列挙攻撃防止）
			//
			// ⚠️ **この分岐があるので、下の wp_mail() の失敗をここで伝えてはいけない。**
			//    伝えると「存在しないアドレス＝必ず成功／存在するアドレス＝失敗し得る」
			//    という差が生まれ、受信を拒否するドメインのアドレスを投げるだけで
			//    会員かどうかが読める。自己登録（SelfRegistration::send_otp()）は
			//    どちらの分岐でも必ず送るので、あちらは失敗を伝えてよい。
			return new \WP_REST_Response( [ 'sent' => true ], 200 );
		}

		$name = $member
			? $member['name_sei'] . ' ' . $member['name_mei']
			: ( $staff['name'] ?? '' );

		// 6桁OTP生成
		$otp = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT );

		// ハッシュしてTransientに保存（有効期限10分）
		$email_hash = Encryption::hash_email( $email );
		$otp_key    = 'conf_otp_' . $email_hash;
		set_transient( $otp_key, wp_hash( $otp ), 600 );

		// 試行回数リセット
		delete_transient( 'conf_otp_attempts_' . $email_hash );

		// メール送信
		$site_name = (string) get_option( 'blogname', '' );

		$subject = sprintf(
			/* translators: %s: site name */
			__( '[%s] My Page Verification Code', 'conferencemanager' ),
			$site_name
		);
		$body  = sprintf(
			/* translators: %s: member name */
			__( 'Dear %s,', 'conferencemanager' ),
			$name
		) . "\n\n";
		$body .= sprintf(
			/* translators: %s: site name */
			__( 'Thank you for using the My Page of %s.', 'conferencemanager' ),
			$site_name
		) . "\n";
		$body .= __( 'Here is your verification code to log in.', 'conferencemanager' ) . "\n\n";
		$body .= sprintf(
			/* translators: %s: OTP code */
			__( 'Verification code: %s', 'conferencemanager' ),
			$otp
		) . "\n\n";
		$body .= __( 'This code is valid for 10 minutes. Open My Page below and enter it in the field on the login screen.', 'conferencemanager' ) . "\n\n";
		// ⚠️ URL は TransactionalMailer に寄せること。別に組み立てると
		//    申込確認メールや他の案内と案内先が食い違う。
		$body .= \ConfManager\Notification\TransactionalMailer::instance()->get_mypage_url() . "\n\n";
		$body .= __( 'Never share this verification code with anyone. Our staff will never ask you for the code by email or phone.', 'conferencemanager' ) . "\n\n";
		$body .= __( 'If you do not recognize this email, please discard it.', 'conferencemanager' ) . "\n";
		$body .= $this->build_mail_footer();

		wp_mail( $email, $subject, $body );

		return new \WP_REST_Response( [ 'sent' => true ], 200 );
	}

	/**
	 * 認証メール共通フッターを生成する
	 *
	 * 差出人組織の identity と連絡先を本文に明示することで、
	 * 素っ気ないコード単体メールがフィッシングとして誤判定され
	 * 迷惑メール振り分け・受信拒否されるリスクを低減する。
	 */
	private function build_mail_footer(): string {
		$site_name  = (string) get_option( 'blogname', '' );
		$admin_mail = (string) get_option( 'admin_email', '' );
		$home_url   = home_url( '/' );

		$footer = "\n" . str_repeat( '━', 26 ) . "\n";
		if ( '' !== $site_name ) {
			$footer .= $site_name . "\n";
		}
		$footer .= sprintf(
			/* translators: %s: site name */
			__( 'This email was sent automatically by the registration system of %s.', 'conferencemanager' ),
			$site_name
		) . "\n";
		if ( '' !== $admin_mail && is_email( $admin_mail ) ) {
			/* translators: %s: administrator email address */
			$footer .= sprintf( __( 'Contact: %s', 'conferencemanager' ), $admin_mail ) . "\n";
		}
		if ( '' !== $home_url ) {
			/* translators: %s: site URL */
			$footer .= sprintf( __( 'Website: %s', 'conferencemanager' ), $home_url ) . "\n";
		}
		return $footer;
	}

	/**
	 * OTP認証コードを検証し、セッショントークンを発行
	 */
	public function verify_otp( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$rate_check = RateLimiter::instance()->check( 'login' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$email = sanitize_email( $request->get_param( 'email' ) ?? '' );
		$otp   = sanitize_text_field( $request->get_param( 'otp' ) ?? '' );

		if ( empty( $email ) || ! is_email( $email ) || empty( $otp ) ) {
			return new \WP_Error( 'invalid_params', __( 'Email address and verification code are required.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$email_hash   = Encryption::instance()->hash_email( $email );
		$otp_key      = 'conf_otp_' . $email_hash;
		$attempts_key = 'conf_otp_attempts_' . $email_hash;

		// 試行回数チェック（最大5回）
		$attempts = (int) get_transient( $attempts_key );
		if ( $attempts >= 5 ) {
			delete_transient( $otp_key );
			return new \WP_Error( 'too_many_attempts', __( 'Maximum attempts reached. Please resend the verification code.', 'conferencemanager' ), [ 'status' => 429 ] );
		}

		$stored_hash = get_transient( $otp_key );
		if ( false === $stored_hash ) {
			return new \WP_Error( 'otp_expired', __( 'Verification code has expired. Please resend.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		if ( ! hash_equals( $stored_hash, wp_hash( $otp ) ) ) {
			set_transient( $attempts_key, $attempts + 1, 600 );
			return new \WP_Error( 'otp_invalid', __( 'Incorrect verification code.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		// OTP消費（ワンタイム）
		delete_transient( $otp_key );
		delete_transient( $attempts_key );

		// 会員またはグループスタッフを取得
		$member = MemberManager::instance()->get_by_email( $email );
		$staff  = null;

		if ( null === $member ) {
			$staff = GroupRegistration::instance()->get_staff_by_email( $email );
		}

		if ( null === $member && null === $staff ) {
			return new \WP_Error( 'not_found', __( 'Member not found.', 'conferencemanager' ), [ 'status' => 404 ] );
		}

		// セッショントークンを発行（30分間有効）
		$token = bin2hex( random_bytes( 32 ) );

		if ( $member ) {
			set_transient( 'conf_otp_session_' . $token, [
				'type'      => 'member',
				'member_id' => (int) $member['id'],
			], 1800 );
		} else {
			set_transient( 'conf_otp_session_' . $token, [
				'type'     => 'staff',
				'staff_id' => (int) $staff['id'],
			], 1800 );
		}

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

	/**
	 * 自己登録: OTP送信
	 */
	public function self_register_send_otp( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$email  = sanitize_email( $request->get_param( 'email' ) ?? '' );
		$result = SelfRegistration::instance()->send_otp( $email );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

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

	/**
	 * 自己登録: OTP検証
	 */
	public function self_register_verify_otp( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$email  = sanitize_email( $request->get_param( 'email' ) ?? '' );
		$otp    = sanitize_text_field( $request->get_param( 'otp' ) ?? '' );
		$result = SelfRegistration::instance()->verify_otp( $email, $otp );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

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

	/**
	 * 自己登録: プロフィール入力して完了
	 */
	public function self_register_complete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$token  = sanitize_text_field( $request->get_param( 'registration_token' ) ?? '' );
		$data   = [
			'name_sei'       => sanitize_text_field( $request->get_param( 'name_sei' ) ?? '' ),
			'name_mei'       => sanitize_text_field( $request->get_param( 'name_mei' ) ?? '' ),
			'name_sei_kana'  => sanitize_text_field( $request->get_param( 'name_sei_kana' ) ?? '' ),
			'name_mei_kana'  => sanitize_text_field( $request->get_param( 'name_mei_kana' ) ?? '' ),
			'org_name'       => sanitize_text_field( $request->get_param( 'org_name' ) ?? '' ),
			'org_dept'       => sanitize_text_field( $request->get_param( 'org_dept' ) ?? '' ),
			'is_member'      => (bool) $request->get_param( 'is_member' ),
			'member_type'    => sanitize_text_field( $request->get_param( 'member_type' ) ?? 'regular' ),
		];
		$result = SelfRegistration::instance()->complete( $token, $data );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return new \WP_REST_Response( $result, 201 );
	}

	/**
	 * マイページ: 団体スタッフ一覧取得
	 */
	public function get_group_staff( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$rate_check = RateLimiter::instance()->check( 'api_request' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$auth_token      = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		$registration_id = absint( $request->get_param( 'registration_id' ) );

		if ( '' === $auth_token ) {
			return new \WP_Error( 'unauthorized', __( 'Authentication required.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$member_id = $this->resolve_member_from_token( $auth_token );
		if ( null === $member_id ) {
			return new \WP_Error( 'invalid_token', __( 'Authentication token is invalid or expired.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		// 代表者の申込か検証
		$registration = RegistrationManager::instance()->get( $registration_id );
		if ( null === $registration || (int) $registration['member_id'] !== $member_id ) {
			return new \WP_Error( 'forbidden', __( 'You do not have permission to view this registration.', 'conferencemanager' ), [ 'status' => 403 ] );
		}

		$staff_list = GroupRegistration::instance()->get_staff_list( $registration_id );
		$enc        = Encryption::instance();

		$items = array_map( function ( $staff ) use ( $enc ) {
			$email = '';
			if ( ! empty( $staff['email'] ) ) {
				if ( is_email( $staff['email'] ) ) {
					$email = $staff['email'];
				} else {
					try {
						$email = $enc->decrypt( $staff['email'] );
					} catch ( \RuntimeException $e ) {
						$email = '';
					}
				}
			}
			return [
				'id'                 => (int) $staff['id'],
				'name'               => $staff['name'],
				'email'              => $email,
				'fee_type'           => $staff['fee_type'] ?? '',
				'participation_type' => $staff['participation_type'],
				'status'             => $staff['status'],
			];
		}, $staff_list );

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

	/**
	 * 団体申込を作成（代表者＋スタッフ）
	 */
	public function create_group_registration( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$bot_check = BotGuard::instance()->verify( $request );
		if ( is_wp_error( $bot_check ) ) {
			return $bot_check;
		}

		$rate_check = RateLimiter::instance()->check( 'registration' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$event_id       = absint( $request->get_param( 'event_id' ) );
		$representative = $request->get_param( 'representative' ) ?? [];
		$staff_list     = $request->get_param( 'staff' ) ?? [];

		if ( empty( $representative['member_id'] ) ) {
			return new \WP_Error( 'missing_representative', __( 'Representative information is required.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		// 代表者の email_hash 検証
		$email_hash = sanitize_text_field( $representative['email_hash'] ?? '' );
		if ( empty( $email_hash ) ) {
			return new \WP_Error( 'missing_email_hash', __( 'Missing authentication credentials.', 'conferencemanager' ), [ 'status' => 400 ] );
		}

		$rep_member = MemberManager::instance()->get( absint( $representative['member_id'] ) );
		if ( null === $rep_member || ! hash_equals( $rep_member['email_hash'], $email_hash ) ) {
			return new \WP_Error( 'invalid_member', __( 'Failed to verify member information.', 'conferencemanager' ), [ 'status' => 403 ] );
		}

		$fee_type = sanitize_text_field( $representative['fee_type'] ?? '' );

		$rep_data = [
			'event_id'           => $event_id,
			'member_id'          => absint( $representative['member_id'] ),
			'participation_type' => sanitize_text_field( $representative['participation_type'] ?? 'onsite' ),
			'payment_method'     => sanitize_text_field( $representative['payment_method'] ?? 'bank' ),
			'session_options'    => $representative['session_options'] ?? [],
			'fee_type'           => $fee_type,
			'transfer_note'      => sanitize_text_field( $representative['transfer_note'] ?? '' ),
		];

		// スタッフデータのサニタイズ
		$sanitized_staff = [];
		foreach ( $staff_list as $staff ) {
			if ( empty( $staff['name'] ) || empty( $staff['email'] ) ) {
				continue;
			}
			$sanitized_staff[] = [
				'name'               => sanitize_text_field( $staff['name'] ),
				'email'              => sanitize_email( $staff['email'] ),
				'fee_type'           => sanitize_text_field( $staff['fee_type'] ?? '' ),
				'participation_type' => sanitize_text_field( $staff['participation_type'] ?? 'onsite' ),
				'session_options'    => $staff['session_options'] ?? [],
				'member_id'          => ! empty( $staff['member_id'] ) ? absint( $staff['member_id'] ) : null,
			];
		}

		$result = GroupRegistration::instance()->create_group( $rep_data, $sanitized_staff );

		if ( is_wp_error( $result ) ) {
			$result->add_data( [ 'status' => 400 ] );
			return $result;
		}

		$reg_id = $result['registration_id'];

		// 銀行振込の場合は決済レコード作成
		$payment_id = 0;
		$payment_method = sanitize_text_field( $representative['payment_method'] ?? 'bank' );

		// Free (total of ¥0) group registrations need no payment. recalculate_group_amount()
		// already confirmed them, so skip the payment record.
		$created_reg = RegistrationManager::instance()->get( $reg_id );
		$reg_is_free = null !== $created_reg
			&& ( (int) $created_reg['amount'] + (int) $created_reg['tax'] ) <= 0;

		if ( $reg_is_free ) {
			$payment_id = 0;
		} elseif ( 'bank' === $payment_method ) {
			$payment_result = BankTransfer::instance()->create_payment( $reg_id );
			if ( ! is_wp_error( $payment_result ) ) {
				$payment_id = (int) $payment_result;
			}
		}

		/**
		 * 団体申込作成後に発火
		 *
		 * @since 1.3.0
		 * @param int   $reg_id     代表者の申込ID
		 * @param int   $payment_id 決済ID
		 * @param array $staff_ids  スタッフID配列
		 */
		do_action( 'conf_manager_group_registration_created', $reg_id, $payment_id, $result['staff_ids'] );

		$registration = RegistrationManager::instance()->get( $reg_id );

		return new \WP_REST_Response( [
			'registration_id' => $reg_id,
			'registration_no' => $registration['registration_no'],
			'group_id'        => $result['group_id'],
			'staff_count'     => count( $result['staff_ids'] ),
			'amount'          => (int) $registration['amount'],
			'tax'             => (int) $registration['tax'],
			'status'          => $registration['status'],
		], 201 );
	}

	/**
	 * マイページ: ログアウト（トークン無効化）
	 */
	public function logout( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$auth_token = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		if ( '' !== $auth_token ) {
			delete_transient( 'conf_otp_session_' . $auth_token );
		}

		return new \WP_REST_Response( [ 'logged_out' => true ], 200 );
	}

	/**
	 * メール配信設定を更新する
	 */
	public function update_email_preferences( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
		$csrf_check = $this->verify_csrf( $request );
		if ( is_wp_error( $csrf_check ) ) {
			return $csrf_check;
		}

		$rate_check = RateLimiter::instance()->check( 'api_request' );
		if ( is_wp_error( $rate_check ) ) {
			return $rate_check;
		}

		$auth_token = sanitize_text_field( $request->get_param( 'auth_token' ) ?? '' );
		if ( '' === $auth_token ) {
			return new \WP_Error( 'unauthorized', __( 'Authentication required.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$member_id = $this->resolve_member_from_token( $auth_token );
		if ( null === $member_id ) {
			return new \WP_Error( 'invalid_token', __( 'Authentication token is invalid or expired.', 'conferencemanager' ), [ 'status' => 401 ] );
		}

		$email_opt_out = (bool) $request->get_param( 'email_opt_out' );

		$result = MemberManager::instance()->update( $member_id, [
			'email_opt_out' => $email_opt_out,
		] );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		AuditLog::instance()->log( 'update_email_preference', 'member', $member_id, [
			'email_opt_out' => $email_opt_out,
		] );

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

	/**
	 * トークンをローテーションする
	 *
	 * 旧トークンを削除し、新トークンを同じセッションデータで発行する。
	 * トークン盗難時の被害を最小化する。
	 *
	 * @param string $old_token 旧トークン
	 * @param array  $session   セッションデータ
	 * @return string 新トークン
	 */
	private function rotate_token( string $old_token, array $session ): string {
		// 旧トークン削除
		delete_transient( 'conf_otp_session_' . $old_token );

		// 新トークン発行（残りTTLではなく新規30分）
		$new_token = bin2hex( random_bytes( 32 ) );
		set_transient( 'conf_otp_session_' . $new_token, $session, 1800 );

		return $new_token;
	}

	/**
	 * auth_token から会員IDを解決（OTPセッション or パスキートークン）
	 */
	private function resolve_member_from_token( string $token ): ?int {
		$session = get_transient( 'conf_otp_session_' . $token );
		if ( false !== $session ) {
			// 新形式（配列）: 会員セッションのみmember_idを返す
			if ( is_array( $session ) ) {
				return 'member' === ( $session['type'] ?? '' ) ? (int) $session['member_id'] : null;
			}
			// 旧形式（int）: 後方互換
			return (int) $session;
		}

		return null;
	}

	/**
	 * トークンからセッション情報を取得（会員・スタッフ両対応）
	 *
	 * @return array{type: string, member_id?: int, staff_id?: int}|null
	 */
	private function resolve_session_from_token( string $token ): ?array {
		$session = get_transient( 'conf_otp_session_' . $token );
		if ( false !== $session ) {
			if ( is_array( $session ) ) {
				return $session;
			}
			// 旧形式（int）: 会員として扱う
			return [ 'type' => 'member', 'member_id' => (int) $session ];
		}

		return null;
	}
}
