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

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


use ConfManager\Traits\Singleton;
use ConfManager\Security\AuditLog;
use ConfManager\Events\EventManager;
use ConfManager\Members\MemberManager;

/**
 * 参加申込管理
 *
 * 参加申込の作成・ステータス管理・料金計算・検索を行う。
 * 登録番号は自動採番（REG-{YYYYMMDD}-{連番5桁}）。
 *
 * @since 1.0.0
 */
class RegistrationManager {

	use Singleton;

	private const TABLE = 'conf_registrations';

	/**
	 * 有効なステータス
	 */
	private const VALID_STATUSES = [ 'pending', 'confirmed', 'paid', 'cancelled', 'attended' ];

	/**
	 * 有効な参加形態
	 */
	private const VALID_PARTICIPATION_TYPES = [ 'onsite', 'online' ];

	/**
	 * 有効な決済手段
	 */
	private const VALID_PAYMENT_METHODS = [ 'bank', 'stripe' ];

	/**
	 * 新規の申込で選べる決済手段
	 *
	 * ⚠️ VALID_PAYMENT_METHODS と分けている理由:
	 * v1.4.0 でカード決済（Stripe）は Pro 版の機能になったが、**過去の申込には
	 * payment_method='stripe' の行が残る**。絞り込み（search）はその行を引けなければ
	 * ならないので VALID_PAYMENT_METHODS には 'stripe' を残し、**新しく作るときだけ**
	 * こちらで拒む。両方を同じ定数にすると、どちらかが必ず壊れる。
	 */
	private const NEW_PAYMENT_METHODS = [ 'bank' ];

	/**
	 * 初期化
	 */
	protected function init(): void {}

	/**
	 * テーブル名
	 */
	private function table(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE;
	}

	/**
	 * 参加申込を作成する
	 *
	 * @param array $data {
	 *     @type int    $event_id           イベントID（必須）
	 *     @type int    $member_id          会員ID（必須）
	 *     @type string $participation_type  参加形態 onsite|online（デフォルト: onsite）
	 *     @type string $payment_method     決済手段 bank|stripe（デフォルト: bank）
	 *     @type array  $session_options    選択セッションID配列
	 *     @type string $discount_code      割引コード
	 *     @type string $group_id           団体ID（団体申込時）
	 *     @type bool   $is_representative  代表者フラグ
	 * }
	 * @return int|\WP_Error 登録ID
	 */
	public function create( array $data ): int|\WP_Error {
		global $wpdb;

		$error = $this->validate( $data );
		if ( is_wp_error( $error ) ) {
			return $error;
		}

		// イベント存在確認
		$event = EventManager::instance()->get( absint( $data['event_id'] ) );
		if ( null === $event ) {
			return new \WP_Error( 'event_not_found', __( 'Event not found.', 'conferencemanager' ) );
		}

		// 会員存在確認
		$member = MemberManager::instance()->get( absint( $data['member_id'] ) );
		if ( null === $member ) {
			return new \WP_Error( 'member_not_found', __( 'Member not found.', 'conferencemanager' ) );
		}

		// 重複申込チェック（トランザクションでrace conditionを防止）
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control, no caching applicable.
		$wpdb->query( 'START TRANSACTION' );

		if ( $this->has_active_registration( absint( $data['event_id'] ), absint( $data['member_id'] ) ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control, no caching applicable.
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error(
				'duplicate_registration',
				__( 'This member has already registered for this event.', 'conferencemanager' )
			);
		}

		$participation_type = sanitize_text_field( $data['participation_type'] ?? 'onsite' );
		$payment_method     = sanitize_text_field( $data['payment_method'] ?? 'bank' );
		$fee_type           = sanitize_text_field( $data['fee_type'] ?? '' );

		// 🔴 料金が設定されていない区分・参加形態は受け付けない（下の $is_free に乗る前に断る）
		$requested_type = '' !== $fee_type ? $fee_type : $member['member_type'];
		if ( ! $this->fee_type_is_offered( $event, $requested_type ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control, no caching applicable.
			$wpdb->query( 'ROLLBACK' );
			return $this->unavailable_fee_type_error();
		}
		if ( ! $this->fee_is_configured( $event, $requested_type, $participation_type ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control, no caching applicable.
			$wpdb->query( 'ROLLBACK' );
			return $this->unavailable_participation_error();
		}

		// 料金計算（fee_type が指定されていればそちらを優先）
		$amount   = $this->calculate_amount( $event, $member, $participation_type, $data['session_options'] ?? [], $fee_type );
		$tax_rate = (float) ( $event['tax_rate'] ?? 10.0 );
		$tax      = $this->calculate_tax( $amount, $tax_rate );

		$registration_no = $this->generate_registration_no();

		// 料金区分キーとラベルを確定
		$resolved_fee_type  = '' !== $fee_type ? $fee_type : $member['member_type'];
		$fee_type_label     = self::resolve_fee_type_label( $resolved_fee_type, $event );

		// Free registrations (total of ¥0) require no payment, so they are confirmed immediately.
		// Paid registrations remain pending until payment is received.
		$is_free = ( $amount + $tax ) <= 0;

		$insert_data = [
			'registration_no'    => $registration_no,
			'event_id'           => absint( $data['event_id'] ),
			'member_id'          => absint( $data['member_id'] ),
			'participation_type' => $participation_type,
			'payment_method'     => $payment_method,
			'fee_type'           => $resolved_fee_type,
			'fee_type_label'     => $fee_type_label,
			'amount'             => $amount,
			'tax'                => $tax,
			'status'             => $is_free ? 'confirmed' : 'pending',
			'registered_at'      => current_time( 'mysql', true ),
		];

		// 振込口座名の申送り（個人名と振込名義が異なる場合の管理用メモ。任意・HTML不可）。
		$transfer_note = sanitize_text_field( $data['transfer_note'] ?? '' );
		if ( '' !== $transfer_note ) {
			$insert_data['transfer_note'] = $transfer_note;
		}

		if ( ! empty( $data['session_options'] ) ) {
			$insert_data['session_options_json'] = wp_json_encode( array_map( 'absint', $data['session_options'] ) );
		}

		if ( ! empty( $data['discount_code'] ) ) {
			$insert_data['discount_code'] = sanitize_text_field( $data['discount_code'] );
		}

		if ( ! empty( $data['group_id'] ) ) {
			$insert_data['group_id'] = sanitize_text_field( $data['group_id'] );
		}

		$insert_data['is_representative'] = ! empty( $data['is_representative'] ) ? 1 : 0;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->insert( $this->table(), $insert_data );

		if ( false === $result ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control, no caching applicable.
			$wpdb->query( 'ROLLBACK' );
			return new \WP_Error(
				'db_insert_error',
				__( 'Failed to create registration.', 'conferencemanager' ),
				$wpdb->last_error
			);
		}

		$reg_id = (int) $wpdb->insert_id;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control, no caching applicable.
		$wpdb->query( 'COMMIT' );

		AuditLog::instance()->log( 'create_registration', 'registration', $reg_id, [
			'event_id'  => $data['event_id'],
			'member_id' => $data['member_id'],
			'amount'    => $amount,
		] );

		return $reg_id;
	}

	/**
	 * 参加申込を取得する
	 *
	 * @param int $id 登録ID
	 * @return array|null
	 */
	public function get( int $id ): ?array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$id
			),
			ARRAY_A
		);

		if ( null === $row ) {
			return null;
		}

		return $this->format_row( $row );
	}

	/**
	 * 登録番号で取得する
	 *
	 * @param string $registration_no 登録番号
	 * @return array|null
	 */
	public function get_by_no( string $registration_no ): ?array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE registration_no = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$registration_no
			),
			ARRAY_A
		);

		if ( null === $row ) {
			return null;
		}

		return $this->format_row( $row );
	}

	/**
	 * 会員のイベント申込一覧を取得する
	 *
	 * @param int $member_id 会員ID
	 * @return array
	 */
	public function get_by_member( int $member_id ): array {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$this->table()} WHERE member_id = %d ORDER BY created_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$member_id
			),
			ARRAY_A
		);

		return array_map( [ $this, 'format_row' ], $rows ?: [] );
	}

	/**
	 * ステータスを変更する
	 *
	 * @param int    $id     登録ID
	 * @param string $status 新ステータス
	 * @return true|\WP_Error
	 */
	public function update_status( int $id, string $status, bool $sync_payment = true, string $paid_at = '' ): true|\WP_Error {
		global $wpdb;

		if ( ! in_array( $status, self::VALID_STATUSES, true ) ) {
			return new \WP_Error( 'invalid_status', __( 'Invalid status.', 'conferencemanager' ) );
		}

		$existing = $this->get( $id );
		if ( null === $existing ) {
			return new \WP_Error( 'not_found', __( 'Registration not found.', 'conferencemanager' ) );
		}

		// ステータス遷移の検証
		$valid_transition = $this->is_valid_transition( $existing['status'], $status );
		if ( ! $valid_transition ) {
			return new \WP_Error(
				'invalid_transition',
				sprintf(
					/* translators: 1: current status, 2: new status */
					__( 'Cannot change status from %1$s to %2$s.', 'conferencemanager' ),
					$existing['status'],
					$status
				)
			);
		}

		// 入金日。
		//
		// ⚠️ **「入金確認をした日」ではなく「受け取り側の通帳に入った日」を記録する。**
		//    入力は現地日付（Y-m-d）で、paid_at は GMT 保存。
		$paid_at_gmt = '' !== $paid_at ? \ConfManager\Support\LocalTime::day_start_to_gmt( $paid_at ) : '';
		if ( '' !== $paid_at && '' === $paid_at_gmt ) {
			return new \WP_Error( 'invalid_paid_at', __( 'Enter the payment date in YYYY-MM-DD format.', 'conferencemanager' ) );
		}

		$update_data = [ 'status' => $status ];

		if ( 'paid' === $status ) {
			$update_data['paid_at'] = '' !== $paid_at_gmt ? $paid_at_gmt : current_time( 'mysql', true );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update( $this->table(), $update_data, [ 'id' => $id ] );

		if ( false === $result ) {
			return new \WP_Error(
				'db_update_error',
				__( 'Failed to update status.', 'conferencemanager' ),
				$wpdb->last_error
			);
		}

		AuditLog::instance()->log( 'update_registration_status', 'registration', $id, [
			'from' => $existing['status'],
			'to'   => $status,
		] );

		// 申込を paid にしたら、その申込の未消込の決済も消し込む。
		//
		// これが無いと連携が片道になる。決済側から消し込んだときは申込も paid に
		// なるが、参加者リストの「入金済にする」は自テーブルしか書かないため、
		// **申込は paid なのに決済は pending のまま**という状態が作れた。
		// 決済行が pending だと、決済管理の一覧にも入金集計にも出ず、
		// **領収書が payment_not_completed で発行できない**（実測で確認）。
		//
		// 消し込みは PaymentManager::confirm_payment() に任せる。入金額の既定
		// （指定が無ければ請求額どおり）・paid_at・監査ログ・
		// conf_manager_payment_confirmed フックがそちらに集約されているので、
		// ここで $wpdb->update() を書くと二重管理になり、片方だけ直る事故を招く。
		//
		// ⚠️ $sync_payment は**相互呼び出しを止めるためだけ**にある。
		// confirm_payment() は最後に update_status( ..., 'paid' ) を呼ぶので、
		// そこから false を渡さないと呼び合いになり、さらに
		// **1 件消し込んだだけで同じ申込の未消込の決済が全部消し込まれる**。
		// ⚠️ この巻き込みは正常系（決済が 1 件だけ）のテストでは全部緑のまま通る。
		// 兄弟の決済を 1 件足して「消し込んだ 1 件だけが completed」を見ること。
		if ( $sync_payment && 'paid' === $status ) {
			// ⚠️ 下の 'pending' 判定は**二重の防御**で、変異させても落ちない。
			// confirm_payment() 自身が pending 以外を invalid_status で弾くため、
			// 外しても振る舞いが変わらない（実測で確認済み＝変異は生き残る）。
			// 全数変異の生存一覧に出たときは「テストが弱い」ではなく
			// 「二重の防御」として仕分けること。**消し込み済みの実入金額を
			// 請求額で塗り潰さない**のは confirm_payment() 側が守っている。
			$payment_mgr = \ConfManager\Payment\PaymentManager::instance();
			foreach ( $payment_mgr->get_by_registration( $id ) as $payment ) {
				if ( 'pending' === $payment['status'] ) {
					// ⚠️ 入金日は決済側にも同じものを渡す。渡さないと申込は通帳の日付・
					//    決済は入金確認日になり、**領収書に出る日付だけがずれる**
					//    （帳票が読むのは決済側の paid_at）。
					$payment_mgr->confirm_payment( (int) $payment['id'], '', '', null, '', $paid_at );
				}
			}
		}

		/*
		 * 🔴 申込を取り下げたら、その申込の**未入金の請求も取り下げる**。
		 *
		 * これが無いと連携がもう一方向で片道になる。上の 'paid' の同期だけが
		 * 在った頃は、申込を cancelled にしても決済は pending のまま残り、
		 * 決済側の読み手（イベント別集計の pending_count / pending_amount・
		 * 決済一覧の「未入金」絞り込み・管理ダッシュボードの未入金件数と合計）は
		 * どれも status='pending' を**正の条件で**数えるので、
		 * **キャンセル済みの申込が未入金として数え続けられていた。**
		 * ダッシュボードは催しで絞らず全件を数えるため、取り消した申込の請求が
		 * いつまでも「未入金」に載り続ける。
		 *
		 * 🔴 入金済（completed / refunded / partial_refund）には触らない。
		 * 受け取った金は申込を取り消しても自動では戻らない。判断は
		 * cancel_payment() 側のガードに集約してあるが、**入金済が残ったこと自体は
		 * 記録に残す**（運用者が精算するかを決める手がかりになる）。
		 */
		if ( $sync_payment && 'cancelled' === $status ) {
			$payment_mgr = \ConfManager\Payment\PaymentManager::instance();
			$received    = [];
			foreach ( $payment_mgr->get_by_registration( $id ) as $payment ) {
				if ( 'pending' === $payment['status'] ) {
					$payment_mgr->cancel_payment( (int) $payment['id'], 'registration_cancelled' );
				} elseif ( in_array( $payment['status'], [ 'completed', 'refunded', 'partial_refund' ], true ) ) {
					$received[] = (int) $payment['id'];
				}
			}

			if ( ! empty( $received ) ) {
				AuditLog::instance()->log( 'cancel_registration_with_received_payment', 'registration', $id, [
					'payment_ids' => $received,
				] );
			}
		}

		/*
		 * 🔴 復活させたら請求も戻す。
		 *
		 * 戻す側が無いと、いったんキャンセルして pending に戻した申込の請求が
		 * cancelled のままになり、督促からも集計からも落ちたままになる。
		 */
		if ( $sync_payment && 'pending' === $status && 'cancelled' === $existing['status'] ) {
			$payment_mgr = \ConfManager\Payment\PaymentManager::instance();
			foreach ( $payment_mgr->get_by_registration( $id ) as $payment ) {
				if ( 'cancelled' === $payment['status'] ) {
					$payment_mgr->restore_cancelled_payment( (int) $payment['id'] );
				}
			}
		}

		return true;
	}

	/**
	 * 申込をキャンセルする
	 *
	 * ⚠️ `$sync_payment` は **相互呼び出しを止めるためだけ**にある
	 * （`update_status()` の 'paid' 側と同じ理由）。`PaymentManager::refund()` は
	 * 全額返金のときにここを呼ぶが、そちらは既に決済側の状態を refunded に
	 * 書いたあとなので、ここから決済側を触り返してはいけない。
	 * 🔴 引数が無かったころは、同じ申込にぶら下がる**兄弟の未消込の決済**まで
	 * 巻き込んで取り下げていた。正常系（決済が 1 件だけ）のテストでは
	 * 全部緑のまま通るので、兄弟を 1 件足したテストでしか捕まらない。
	 *
	 * @param int  $id           登録ID
	 * @param bool $sync_payment 決済側も同期するか
	 * @return true|\WP_Error
	 */
	public function cancel( int $id, bool $sync_payment = true ): true|\WP_Error {
		return $this->update_status( $id, 'cancelled', $sync_payment );
	}

	/**
	 * 参加区分（料金区分・参加形態）を管理者が修正し、料金を再計算する
	 *
	 * 個人申込（非団体）かつ未入金（pending/confirmed）のみ対象。
	 * 金額・税・ラベル・ステータスを再計算し、未消込の決済レコードと請求書 PDF も同期する。
	 *
	 * @param int    $id                 登録ID
	 * @param string $fee_type           新しい料金区分キー
	 * @param string $participation_type 新しい参加形態 onsite|online
	 * @return array|\WP_Error 更新後の値 [ amount, tax, fee_type, fee_type_label, participation_type, status ]
	 */
	public function update_fee_classification( int $id, string $fee_type, string $participation_type ): array|\WP_Error {
		global $wpdb;

		$registration = $this->get( $id );
		if ( null === $registration ) {
			return new \WP_Error( 'not_found', __( 'Registration not found.', 'conferencemanager' ) );
		}

		// 個人申込限定（団体は専用フローで再計算）
		if ( ! empty( $registration['group_id'] ) || ! empty( $registration['is_representative'] ) ) {
			return new \WP_Error( 'group_not_supported', __( 'Fee classification of group registrations cannot be edited here. Use staff management instead.', 'conferencemanager' ) );
		}

		// キャンセル済・出席済は訂正不可。入金済（paid）は精算（返金／追加徴収）を
		// 前提に訂正を許可する（区分の誤りは入金後に発覚することが多いため）。
		if ( ! in_array( $registration['status'], [ 'pending', 'confirmed', 'paid' ], true ) ) {
			return new \WP_Error( 'not_editable', __( 'Cancelled or attended registrations cannot be edited.', 'conferencemanager' ) );
		}

		$payments = \ConfManager\Payment\PaymentManager::instance()->get_by_registration( $id );
		foreach ( $payments as $payment ) {
			if ( in_array( $payment['status'], [ 'refunded', 'partial_refund' ], true ) ) {
				return new \WP_Error(
					'payment_refunded',
					__( 'Registrations with a refunded payment cannot be edited.', 'conferencemanager' )
				);
			}
		}

		// 参加形態の検証
		if ( ! in_array( $participation_type, self::VALID_PARTICIPATION_TYPES, true ) ) {
			return new \WP_Error( 'invalid_participation_type', __( 'Invalid participation type.', 'conferencemanager' ) );
		}

		$event  = EventManager::instance()->get( (int) $registration['event_id'] );
		$member = MemberManager::instance()->get( (int) $registration['member_id'] );
		if ( null === $event || null === $member ) {
			return new \WP_Error( 'not_found', __( 'Event or member not found.', 'conferencemanager' ) );
		}

		// 料金区分の検証（イベントの料金ルールに存在すること。`_` 始まりのメタキーは除外）
		$resolved = EventManager::instance()->resolve_fee_rules( $event );
		if ( str_starts_with( $fee_type, '_' ) || ! isset( $resolved[ $fee_type ] ) ) {
			return new \WP_Error( 'invalid_fee_type', __( 'Invalid fee classification for this event.', 'conferencemanager' ) );
		}

		// 🔴 料金が設定されていない参加形態へは訂正できない（create() と同じ理由）
		if ( ! $this->fee_is_configured( $event, $fee_type, $participation_type ) ) {
			return $this->unavailable_participation_error();
		}

		$old_fee_type = (string) $registration['fee_type'];
		$old_amount   = (int) $registration['amount'];

		$amount = $this->calculate_amount( $event, $member, $participation_type, $registration['session_options'], $fee_type );
		$tax    = $this->calculate_tax( $amount, (float) ( $event['tax_rate'] ?? 10.0 ) );
		$label  = self::resolve_fee_type_label( $fee_type, $event );

		// ステータス再評価。入金済（paid）を未入金へ戻さないよう、未入金の範囲でのみ再判定する。
		$status = in_array( $registration['status'], [ 'pending', 'confirmed' ], true )
			? ( ( $amount + $tax ) <= 0 ? 'confirmed' : 'pending' )
			: (string) $registration['status'];

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$result = $wpdb->update(
			$this->table(),
			[
				'fee_type'           => $fee_type,
				'fee_type_label'     => $label,
				'participation_type' => $participation_type,
				'amount'             => $amount,
				'tax'                => $tax,
				'status'             => $status,
			],
			[ 'id' => $id ]
		);

		if ( false === $result ) {
			return new \WP_Error( 'db_update_error', __( 'Failed to update the registration.', 'conferencemanager' ), $wpdb->last_error );
		}

		// 決済レコードの請求額を同期する。入金済の行は paid_amount（実入金額）を据え置き、
		// 請求額のみ正しい値に直すことで差額が精算対象として自動的に立つ。
		\ConfManager\Payment\PaymentManager::instance()->sync_billed_amount( $id, $amount, $tax );

		// ⭐ PDF の作り直しは要らない。帳票は**要求のたびに DB から描画する**ので、
		//    請求額を直した時点で次のダウンロードに反映される。
		//    以前はここでディスク上の PDF を上書き再生成しており、失敗しても
		//    ログに落とすだけ＝**古い金額の PDF が残ったまま気づけない**状態だった。

		AuditLog::instance()->log( 'update_fee_classification', 'registration', $id, [
			'old_fee_type'       => $old_fee_type,
			'new_fee_type'       => $fee_type,
			'participation_type' => $participation_type,
			'old_amount'         => $old_amount,
			'new_amount'         => $amount,
		] );

		/**
		 * 参加区分の管理者修正後に発火するフック。
		 *
		 * @param int $id     登録ID
		 * @param int $amount 新しい税抜金額
		 * @param int $tax    新しい消費税額
		 */
		do_action( 'conf_manager_registration_fee_corrected', $id, $amount, $tax );

		return [
			'amount'             => $amount,
			'tax'                => $tax,
			'fee_type'           => $fee_type,
			'fee_type_label'     => $label,
			'participation_type' => $participation_type,
			'status'             => $status,
		];
	}

	/**
	 * 申込を検索する
	 *
	 * @param array $args {
	 *     @type int    $event_id           イベントIDで絞り込み
	 *     @type int    $member_id          会員IDで絞り込み
	 *     @type string $status             ステータスで絞り込み
	 *     @type string $participation_type 参加形態で絞り込み
	 *     @type string $payment_method     決済手段で絞り込み
	 *     @type string $group_id           団体IDで絞り込み
	 *     @type string $keyword            登録番号・団体IDで部分一致
	 *     @type string $orderby            ソートカラム
	 *     @type string $order              ASC|DESC
	 *     @type int    $per_page           件数
	 *     @type int    $page               ページ番号
	 * }
	 * @return array{items: array, total: int, page: int, per_page: int}
	 */
	public function search( array $args = [] ): array {
		global $wpdb;

		$table    = $this->table();
		$where    = [];
		$values   = [];
		$per_page = min( 100, max( 1, absint( $args['per_page'] ?? 20 ) ) );
		$page     = max( 1, absint( $args['page'] ?? 1 ) );

		if ( ! empty( $args['event_id'] ) ) {
			$where[]  = 'event_id = %d';
			$values[] = absint( $args['event_id'] );
		}

		if ( ! empty( $args['member_id'] ) ) {
			$where[]  = 'member_id = %d';
			$values[] = absint( $args['member_id'] );
		}

		if ( ! empty( $args['status'] ) && in_array( $args['status'], self::VALID_STATUSES, true ) ) {
			$where[]  = 'status = %s';
			$values[] = $args['status'];
		}

		if ( ! empty( $args['participation_type'] ) && in_array( $args['participation_type'], self::VALID_PARTICIPATION_TYPES, true ) ) {
			$where[]  = 'participation_type = %s';
			$values[] = $args['participation_type'];
		}

		if ( ! empty( $args['payment_method'] ) && in_array( $args['payment_method'], self::VALID_PAYMENT_METHODS, true ) ) {
			$where[]  = 'payment_method = %s';
			$values[] = $args['payment_method'];
		}

		if ( ! empty( $args['group_id'] ) ) {
			$where[]  = 'group_id = %s';
			$values[] = sanitize_text_field( $args['group_id'] );
		}

		if ( ! empty( $args['keyword'] ) ) {
			$keyword  = '%' . $wpdb->esc_like( sanitize_text_field( $args['keyword'] ) ) . '%';
			$where[]  = '(registration_no LIKE %s OR group_id LIKE %s)';
			$values[] = $keyword;
			$values[] = $keyword;
		}

		$where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';

		$allowed_orderby = [ 'id', 'registration_no', 'event_id', 'amount', 'status', 'registered_at', 'created_at' ];
		$orderby         = in_array( $args['orderby'] ?? 'id', $allowed_orderby, true )
			? $args['orderby'] ?? 'id'
			: 'id';
		$order           = 'ASC' === strtoupper( $args['order'] ?? 'DESC' ) ? 'ASC' : 'DESC';

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $table is a hardcoded WP prefix table; $where_sql is built from validated %s/%d placeholders; values pass through $wpdb->prepare().
		$count_sql = "SELECT COUNT(*) FROM {$table} {$where_sql}";
		$total     = ! empty( $values )
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic query built with safe values
			? (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$values ) )
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic query built with safe values
			: (int) $wpdb->get_var( $count_sql );

		$offset       = ( $page - 1 ) * $per_page;
		$query_values = array_merge( $values, [ $per_page, $offset ] );
		// ORDER BY の末尾に id を足して並びを一意にする。
		// 一意でないまま LIMIT/OFFSET で頁を送ると、頁をまたぐ順序が保証されず
		// **同じ行が 2 度来たり 1 行落ちたりする**。「100 件で切れる」より質が悪く、
		// 「たまに 1 件足りない」は誰も気づけない。しかも InnoDB はたいてい安定して
		// 返すため**テストでは守れない**ので、構造として固定する（M-12 が検査する）。
		$data_sql     = "SELECT * FROM {$table} {$where_sql} ORDER BY {$orderby} {$order}, id {$order} LIMIT %d OFFSET %d";
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic query built with safe values
		$rows         = $wpdb->get_results(
			$wpdb->prepare( $data_sql, ...$query_values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$items = array_map( [ $this, 'format_row' ], $rows ?: [] );

		return [
			'items'    => $items,
			'total'    => $total,
			'page'     => $page,
			'per_page' => $per_page,
		];
	}

	/**
	 * イベントの申込件数を取得
	 *
	 * @param int    $event_id イベントID
	 * @param string $status   ステータス（空=全件, cancelled除外）
	 * @return int
	 */
	public function count_by_event( int $event_id, string $status = '' ): int {
		global $wpdb;

		if ( '' !== $status ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
			return (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$this->table()} WHERE event_id = %d AND status = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
					$event_id,
					$status
				)
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$this->table()} WHERE event_id = %d AND status != 'cancelled'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$event_id
			)
		);
	}

	/**
	 * その参加形態に料金が設定されているか
	 *
	 * 🔴 **`?? 0` は「禁止」を「無料」に変える。**
	 * 料金表にその区分の行があるのに参加形態の列が未入力だと、
	 * calculate_amount() の `?? 0` で基本料金が 0 になり、0 円の申込は
	 * 支払い不要として **即 confirmed** に確定する経路に乗る。決済レコードも
	 * 作られないので決済管理にも現れず、「登録はできたのに請求が来ない」と
	 * いう形でしか表面化しない。
	 *
	 * 管理画面の料金表は列を常に出すが、担当者が埋めなければ**キー自体が入らない**
	 * （入力して消した場合は 0 が入る）。**「未設定」と「明示的に 0 円」は区別できる**ので、
	 * 前者だけを断る。
	 *
	 * ⚠️ 範囲を広げないこと:
	 * - 料金表が丸ごと空のイベントは**従来どおり無料**（何も設定していない＝無料イベント）
	 * - その区分の行自体が無い場合も**従来どおり**（例: 名誉会員を無料にする運用）
	 * 断るのは「行はあるのに、その参加形態だけ埋まっていない」ときに限る。
	 *
	 * @param array  $event              イベントデータ
	 * @param string $type_key           料金区分キー（会員種別 or fee_type）
	 * @param string $participation_type 参加形態
	 */
	public function fee_is_configured( array $event, string $type_key, string $participation_type ): bool {
		if ( ! $this->fee_type_is_offered( $event, $type_key ) ) {
			return false;
		}

		$resolved = $this->fee_table( $event );

		// 料金表そのものが無い催しは従来どおり無料で通す（上の判定で true が返っている）
		if ( empty( $resolved ) ) {
			return true;
		}

		return array_key_exists( $participation_type, (array) $resolved[ $type_key ] );
	}

	/**
	 * その料金区分をこの催しが受け付けているか
	 *
	 * 🔴 **料金表に行が無い区分は「受け付けない」。**
	 * 以前はここで true を返しており、行の無い区分は calculate_amount() の `?? 0` に
	 * 落ちて **¥0・即確定**になっていた（管理画面の料金表から ✖️ で外した区分も同じ）。
	 *
	 * ⭐ これは新しい方針ではなく**食い違いの解消**。参加者リストの区分訂正
	 * （update_fee_classification()）は以前から `! isset( $resolved[ $fee_type ] )` を
	 * `invalid_fee_type` で断っており、新規申込だけが例外的に通していた。
	 *
	 * 📌 **会員が締め出されることは無い。** 公開の申込フォームは「行のある区分」しか
	 * 選択肢に出さず、会員自身の区分に行が無ければ先頭の有効な区分へ自動的に倒す
	 * （`RegistrationForm.jsx` の resolveFeeType / getFeeTypeOptions、団体側も同じ）。
	 *
	 * ⚠️ 料金表が丸ごと空の催しは従来どおり通す（＝無料イベント。別の話）。
	 *
	 * @param array  $event    イベントデータ
	 * @param string $type_key 料金区分キー
	 */
	public function fee_type_is_offered( array $event, string $type_key ): bool {
		$resolved = $this->fee_table( $event );

		if ( empty( $resolved ) ) {
			return true;
		}

		return isset( $resolved[ $type_key ] ) && is_array( $resolved[ $type_key ] );
	}

	/**
	 * 適用される料金表（ラベル用のメタキーを除いたもの）
	 */
	private function fee_table( array $event ): array {
		$resolved = \ConfManager\Events\EventManager::instance()->resolve_fee_rules( $event );
		unset( $resolved['_labels'] );

		return $resolved;
	}

	/**
	 * 料金が設定されていない参加形態のエラー
	 */
	private function unavailable_fee_type_error(): \WP_Error {
		return new \WP_Error(
			'fee_type_unavailable',
			__( 'The selected fee category is not offered for this event. Please choose another one or contact the organizer.', 'conferencemanager' ),
			[ 'status' => 400 ]
		);
	}

	/**
	 * 料金が設定されていない参加形態のエラー
	 */
	private function unavailable_participation_error(): \WP_Error {
		return new \WP_Error(
			'participation_type_unavailable',
			__( 'The selected participation type is not available for this event. Please choose another one or contact the organizer.', 'conferencemanager' ),
			[ 'status' => 400 ]
		);
	}

	/**
	 * 料金を計算する
	 *
	 * @param array  $event              イベントデータ
	 * @param array  $member             会員データ
	 * @param string $participation_type 参加形態
	 * @param array  $session_options    選択セッションID配列
	 * @return int 合計金額（円）
	 */
	public function calculate_amount( array $event, array $member, string $participation_type, array $session_options = [], string $fee_type = '' ): int {
		// 基本料金（fee_type が指定されていればそちらで検索、なければ会員種別で検索）
		$type_key = '' !== $fee_type ? $fee_type : $member['member_type'];
		$resolved = \ConfManager\Events\EventManager::instance()->resolve_fee_rules( $event );
		$base_fee = (int) ( $resolved[ $type_key ][ $participation_type ] ?? 0 );

		// セッション追加料金
		$session_fee = 0;
		if ( ! empty( $session_options ) ) {
			$session_mgr = \ConfManager\Events\SessionManager::instance();
			foreach ( $session_options as $session_id ) {
				$session = $session_mgr->get( absint( $session_id ) );
				if ( null !== $session && (int) $session['event_id'] === (int) $event['id'] ) {
					$session_fee += (int) $session['fee'];
				}
			}
		}

		return $base_fee + $session_fee;
	}

	/**
	 * 消費税を計算する
	 *
	 * @param int   $amount   税抜金額
	 * @param float $tax_rate 税率（%）。0.0 で非課税。デフォルト 10.0
	 * @return int 消費税額
	 */
	public function calculate_tax( int $amount, float $tax_rate = 10.0 ): int {
		if ( $tax_rate <= 0.0 ) {
			return 0;
		}
		return (int) floor( $amount * $tax_rate / 100 );
	}

	/**
	 * 料金区分キーからラベルを解決する
	 *
	 * 標準会員種別 → 定義済みラベル、カスタム種別 → fee_rules._labels から取得。
	 */
	/**
	 * 標準の会員種別（料金区分）の表示名を返す。
	 *
	 * 🔴 **会員種別の表示名を PHP 側でここ以外に書かないこと。**
	 * もともと決済 CSV（`includes/payment/class-payment-api.php`）に
	 * **2 つ目の生の配列**（`'regular' => '正会員'` のような日本語ベタ書き・
	 * 翻訳を通さない）があり、同じ人の同じ区分が経路によって別表記になりうる状態だった。
	 * 英語ソースの Free に日本語が直接書かれてもいた。
	 *
	 * 静的検査 `labels-2` が 2 つ目の定義の再発を止める。
	 *
	 * ⚠️ カスタム区分はここには入らない（催しの `fee_rules._labels` が持つ）。
	 * 標準 5 区分の解決は resolve_fee_type_label() を使うこと。
	 *
	 * @return array<string, string> キー => 表示名
	 */
	public static function base_labels(): array {
		return [
			'regular'   => __( 'Regular Member', 'conferencemanager' ),
			'student'   => __( 'Student Member', 'conferencemanager' ),
			'associate' => __( 'Associate Member', 'conferencemanager' ),
			'honorary'  => __( 'Honorary Member', 'conferencemanager' ),
			'general'   => __( 'Non-member', 'conferencemanager' ),
		];
	}

	public static function resolve_fee_type_label( string $fee_type, array $event ): string {
		$standard = self::base_labels();

		if ( isset( $standard[ $fee_type ] ) ) {
			return $standard[ $fee_type ];
		}

		// カスタム種別: イベントの fee_rules._labels から取得
		$rules = $event['fee_rules'] ?? [];
		if ( is_string( $rules ) ) {
			$rules = json_decode( $rules, true ) ?: [];
		}

		return $rules['_labels'][ $fee_type ] ?? $fee_type;
	}

	/**
	 * 同一イベント・同一会員のアクティブな申込があるか
	 */
	public function has_active_registration( int $event_id, int $member_id ): bool {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		return (bool) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$this->table()} WHERE event_id = %d AND member_id = %d AND status != 'cancelled' FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$event_id,
				$member_id
			)
		);
	}

	/**
	 * 登録番号を生成する
	 *
	 * フォーマット: REG-{YYYYMMDD}-{連番5桁}
	 */
	private function generate_registration_no(): string {
		global $wpdb;

		$date_prefix = 'REG-' . gmdate( 'Ymd' ) . '-';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$last = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT registration_no FROM {$this->table()} WHERE registration_no LIKE %s ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$date_prefix . '%'
			)
		);

		if ( $last ) {
			$seq = (int) substr( $last, -5 ) + 1;
		} else {
			$seq = 1;
		}

		return $date_prefix . str_pad( (string) $seq, 5, '0', STR_PAD_LEFT );
	}

	/**
	 * ステータス遷移の検証
	 *
	 * 許可される遷移:
	 * pending   → confirmed, paid, cancelled
	 * confirmed → paid, cancelled
	 * paid      → attended, cancelled
	 * cancelled → pending（誤キャンセルからの復元）
	 * attended  → (遷移不可)
	 */
	private function is_valid_transition( string $from, string $to ): bool {
		$transitions = [
			'pending'   => [ 'confirmed', 'paid', 'cancelled' ],
			'confirmed' => [ 'paid', 'cancelled' ],
			'paid'      => [ 'attended', 'cancelled' ],
			'cancelled' => [ 'pending' ],
			'attended'  => [],
		];

		return in_array( $to, $transitions[ $from ] ?? [], true );
	}

	/**
	 * DB行を整形する
	 */
	private function format_row( array $row ): array {
		if ( ! empty( $row['session_options_json'] ) ) {
			$row['session_options'] = json_decode( $row['session_options_json'], true ) ?: [];
		} else {
			$row['session_options'] = [];
		}

		return $row;
	}

	/**
	 * バリデーション
	 */
	private function validate( array $data ): true|\WP_Error {
		if ( empty( $data['event_id'] ) ) {
			return new \WP_Error( 'missing_event_id', __( 'Event ID is required.', 'conferencemanager' ) );
		}

		if ( empty( $data['member_id'] ) ) {
			return new \WP_Error( 'missing_member_id', __( 'Member ID is required.', 'conferencemanager' ) );
		}

		if ( isset( $data['participation_type'] ) && ! in_array( $data['participation_type'], self::VALID_PARTICIPATION_TYPES, true ) ) {
			return new \WP_Error( 'invalid_participation_type', __( 'Invalid participation type.', 'conferencemanager' ) );
		}

		if ( isset( $data['payment_method'] ) && ! in_array( $data['payment_method'], self::NEW_PAYMENT_METHODS, true ) ) {
			return new \WP_Error( 'invalid_payment_method', __( 'Invalid payment method.', 'conferencemanager' ) );
		}

		return true;
	}

	/**
	 * 有効なステータス一覧
	 */
	public static function get_valid_statuses(): array {
		return self::VALID_STATUSES;
	}
}
