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

use ConfManager\Payment\PaymentAdjustmentManager;

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


/**
 * プラグイン有効化時の処理
 *
 * 全テーブルの作成とDBバージョン管理を行う。
 * テーブル定義は docs/DATABASE.md に準拠。
 *
 * @since 1.0.0
 */
class Activator {

	/**
	 * 有効化処理
	 */
	public static function activate(): void {
		self::create_tables();
		self::ensure_encryption_key();
		self::create_pages();

		// バージョンを直接書かず、必ず移行を通してから確定させる。
		//
		// 以前はここで update_option( 'conf_manager_db_version', ... ) を直接
		// 呼んでいた。そのため「停止 → フォルダ差し替え → 有効化」で更新した
		// 環境では、**バージョンだけが上がって移行が一度も走らない**。
		// maybe_upgrade() は admin_init に載っているが、有効化の時点で既に
		// 最新版数が書かれているので永久にスキップされる。テーブル定義は
		// create_tables() が拾うため、飛ぶのはバックフィルだけ＝症状は
		// 「新機能が効かない」「数字が合わない」としか出ず、原因に辿り着けない。
		//
		// maybe_upgrade() は版数が最新なら何もしない。create_tables() の
		// 二重呼び出しになる場合があるが dbDelta は冪等なので問題ない。
		self::maybe_upgrade();

		// 現在の鍵のハッシュを基準値として控える。以後この値と食い違えば
		// 「鍵が差し替わった」と検知できる。既に基準値があれば上書きしない
		// （上書きすると変更の証拠が消えるため）。
		\ConfManager\Security\KeyRotation::instance()->remember_current_key();
		\ConfManager\Security\KeyRotation::instance()->register_current_key();

		Capabilities::register_roles();
		flush_rewrite_rules();
	}

	/**
	 * 暗号化キーが存在しない場合、自動生成して wp_options に保存
	 */
	private static function ensure_encryption_key(): void {
		// wp-config.php に定義済みなら何もしない
		if ( defined( 'CONF_MANAGER_ENCRYPTION_KEY' ) && strlen( CONF_MANAGER_ENCRYPTION_KEY ) >= 16 ) {
			return;
		}

		// wp_options に既にあれば何もしない
		$existing = get_option( 'conf_manager_encryption_key', '' );
		if ( ! empty( $existing ) && strlen( $existing ) >= 16 ) {
			return;
		}

		// 自動生成（64文字の16進数 = 256bit）
		$key = bin2hex( random_bytes( 32 ) );
		update_option( 'conf_manager_encryption_key', $key, false );
	}

	/**
	 * 固定ページを自動作成（既存チェック付き）
	 *
	 * プラグイン有効化時に必要な固定ページを作成する。
	 * 同じスラッグの公開済みページが既に存在する場合はスキップ。
	 */
	private static function create_pages(): void {
		$pages = [
			[
				'slug'    => 'c-registration',
				'title'   => __( 'Registration', 'conferencemanager' ),
				'content' => '[conf_manager]',
			],
			[
				'slug'    => 'c-mypage',
				'title'   => __( 'My Page', 'conferencemanager' ),
				'content' => '[conf_manager view="mypage"]',
			],
			[
				'slug'    => 'c-terms',
				'title'   => __( 'Terms of Service', 'conferencemanager' ),
				'content' => self::get_terms_content(),
			],
		];

		foreach ( $pages as $page ) {
			// 同じスラッグの公開済みページが存在するかチェック
			$existing = get_posts( [
				'name'        => $page['slug'],
				'post_type'   => 'page',
				'post_status' => [ 'publish', 'draft', 'private' ],
				'numberposts' => 1,
			] );

			if ( ! empty( $existing ) ) {
				continue;
			}

			wp_insert_post( [
				'post_type'    => 'page',
				'post_title'   => $page['title'],
				'post_name'    => $page['slug'],
				'post_content' => $page['content'],
				'post_status'  => 'publish',
			] );
		}
	}

	/**
	 * 利用規約ページのデフォルトコンテンツを返す
	 */
	private static function get_terms_content(): string {
		// $site_name はテンプレート内で esc_html() を適用するため、ここでは生値を渡す。
		$site_name = get_bloginfo( 'name' );

		ob_start();
		include CONF_MANAGER_PLUGIN_DIR . 'templates/default-terms-content.php';
		return (string) ob_get_clean();
	}

	/**
	 * DBバージョンチェックとアップグレード
	 */
	public static function maybe_upgrade(): void {
		$current = get_option( 'conf_manager_db_version', '0' );
		if ( version_compare( $current, CONF_MANAGER_DB_VERSION, '<' ) ) {
			self::create_tables();

			// v3.0.0 でスケジュール通知機能を廃止したため、旧 Cron スケジュールをクリア。
			if ( version_compare( $current, '3.0.0', '<' ) ) {
				wp_clear_scheduled_hook( 'conf_manager_payment_reminder' );
				wp_clear_scheduled_hook( 'conf_manager_notification_send' );
			}

			// v3.2.0: 消込済み決済の実入金額（paid_amount）を請求額で確定させる。
			// 未記録のままだと、参加区分の訂正で請求額が変わったときに実入金額の
			// 代替表示まで追随してしまい、差額が 0 のままとなって精算
			// （要返金／要追加徴収）が検出できない。
			if ( version_compare( $current, '3.2.0', '<' ) ) {
				self::backfill_paid_amount();
			}

			// v3.3.0: 精算明細の導入より前に行われた返金を明細へ移す。
			// 移行しないと旧返金が集計から抜け落ち、純入金額が過大になる。
			if ( version_compare( $current, '3.3.0', '<' ) ) {
				self::migrate_legacy_refunds_to_ledger();
			}

			// v3.4.0: 上の 2 つを「取りこぼした」環境を回収する。
			//
			// v1.1.11 より前の activate() は移行を通さずに DB バージョンだけを
			// 書いていた。そのため「停止 → フォルダ差し替え → 有効化」で更新した
			// 環境は **バージョンが 3.3.0 なのにバックフィルが一度も走っていない**。
			// v1.1.11 で activate() は直したが、版数が既に最新なので上のブロックは
			// どれも条件に当たらず、取りこぼしは自力では回収されない。
			// ここで版数に関係なくもう一度流して回収する。
			//
			// 両方とも対象を「まだ移行されていない行」だけに絞る（paid_amount が
			// NULL ／ 返金明細が 1 件も無い）ので、健全な環境では 0 行に当たって
			// 何も起きない。3.2.0 / 3.3.0 のブロックが同じ呼び出しで先に走った
			// 場合も、その時点で対象が消えているため二重には積まれない。
			if ( version_compare( $current, '3.4.0', '<' ) ) {
				self::backfill_paid_amount();
				self::migrate_legacy_refunds_to_ledger();
			}

			// v3.5.0: 既に論理削除されている会員から UNIQUE キーを退避させる。
			// これが無いと、更新しても「過去に消したアドレス」だけが再登録できないまま残る。
			if ( version_compare( $current, '3.5.0', '<' ) ) {
				self::tombstone_deleted_member_keys();
			}

			// v3.6.0: uploads 配下に置かれた請求書・領収書 PDF を回収する。
			// 帳票は要求のたびに描画する方式へ変えたので、置きっぱなしのファイルは
			// **推測可能な公開 URL のまま残る**（連番のファイル名）。
			if ( version_compare( $current, '3.6.0', '<' ) ) {
				self::remove_public_document_files();
			}

			// v3.7.0: 「料金表に行が無い区分」を受け付けない方式へ変える前に、
			// **実際に ¥0 で申し込まれてきた組み合わせ**を明示的な 0 として書き起こす。
			// これをしないと、行を空欄にしたまま無料で運用していた区分の申込が止まる。
			if ( version_compare( $current, '3.7.0', '<' ) ) {
				self::materialise_observed_free_fees();
			}

			// v3.8.0: 同じ書き起こしを**団体スタッフのぶんも**行う。
			// 🔴 3.7.0 は `conf_registrations` しか見ておらず、スタッフの区分は
			// `conf_group_members` にあって申込側には現れない（代表者の amount は
			// 団体の合計）ため、**1 件も拾えていなかった**。拾わないと既存の団体に
			// 同じ区分のスタッフを追加できない。⚠️ 3.7.0 を通過済みのサイトでは
			// この版でしか走らないので、版を分けて置く（何度走っても
			// **無かったセルにしか書かない**ので安全）。
			if ( version_compare( $current, '3.8.0', '<' ) ) {
				self::materialise_observed_free_fees();
			}

			// v3.9.0: 精算明細に取消（論理削除）の 2 列を足す。
			// ⚠️ **データの書き換えは要らない**。列は DEFAULT NULL で追加され、
			// 既存の明細はすべて `deleted_at IS NULL` ＝「生きている」と読まれる
			// （集計の除外条件と同じ判定）。上の版のようなバックフィルを
			// 置かないのはそのため。列そのものは create_tables() の dbDelta が足す。
			// 🔴 列が足りているかは Tier 1 の A-1g が見る。**この版の集計 SQL は
			// deleted_at を参照する**ので、列の無い DB で走らせると SQL エラーになり、
			// その HTML が検査の出力に混ざって別の項目が行方不明になる（Pro で実測）。

			// v3.10.0: 申込がキャンセル済みなのに未入金のまま残っている請求を
			// 取り下げる（決済側に 'cancelled' を足したことの遡及適用）。
			if ( version_compare( $current, '3.10.0', '<' ) ) {
				self::cancel_orphaned_pending_payments();
			}

			update_option( 'conf_manager_db_version', CONF_MANAGER_DB_VERSION );
		}

		// ロール・ケイパビリティの更新（cap追加時に再登録）
		$roles_ver = get_option( 'conf_manager_roles_version', '0' );
		if ( version_compare( $roles_ver, '2', '<' ) ) {
			Capabilities::register_roles();
			update_option( 'conf_manager_roles_version', '2' );
		}
	}

	/**
	 * 実績のある無料の（区分 × 参加形態）を、明示的な 0 円として料金表に書き起こす（v3.7.0）
	 *
	 * ## なぜ要るか
	 *
	 * v1.5.0 から「料金表に行が無い区分は受け付けない」に変える。それまでは
	 * 行が無くても `?? 0` で ¥0・即確定になっていたため、**行を空欄にしたまま
	 * 無料で運用していた区分**（名誉会員を無料にする等）が実在しうる。
	 * そのまま切り替えるとその申込が止まるので、**実際に起きていた運用だけ**を
	 * データから拾って明示的な 0 円に変換しておく。
	 *
	 * 一度も申し込まれたことのない組み合わせは書き起こさない＝**そこだけが
	 * 「受け付けない」に変わる**（元々誰も通っていないので実害が無い）。
	 *
	 * ## 触らないもの（🔴 ここを外すと既存の催しが壊れる）
	 *
	 * - **料金表が丸ごと空の催し**。1 行でも書くと「無料イベント」から
	 *   「この区分だけ提供」に変わり、**他の区分の申込が止まる**。
	 * - **空の当日料金表**。`resolve_fee_rules()` は当日表が非空のときだけ
	 *   二段階価格に切り替えるので、書き込むと**その催しが当日価格に化ける**。
	 * - 既に値の入っているセル（上書きしない。0 を足すだけ）。
	 * - 申込・決済・帳票のデータ（一切書かない）。
	 */
	/**
	 * キャンセル済みの申込にぶら下がったままの未入金の請求を取り下げる
	 *
	 * 決済側に 'cancelled' を足す前は、申込を取り消しても決済は pending のまま
	 * 残っていた。決済側の読み手はどれも status='pending' を**正の条件で**
	 * 数えるので、そのままではキャンセル済みが未入金として数え続けられる。
	 *
	 * 🔴 **WHERE の 2 条件はどちらも落とせない。**
	 *
	 * | 落とすと | 起きること |
	 * |---|---|
	 * | `r.status` | 生きている請求が全部消える（未入金 0 件になり督促できなくなる） |
	 * | `p.status` | 受け取った入金が入金合計から消え、返金の履歴も切れる |
	 *
	 * ⚠️ 何度走っても同じ（既に cancelled の行は p.status='pending' に当たらない）。
	 */
	private static function cancel_orphaned_pending_payments(): void {
		global $wpdb;

		$payments      = $wpdb->prefix . 'conf_payments';
		$registrations = $wpdb->prefix . 'conf_registrations';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from the plugin-defined prefix, not user input; this migration takes no user values.
		$wpdb->query(
			"UPDATE {$payments} p
			 INNER JOIN {$registrations} r ON r.id = p.registration_id
			 SET p.status = 'cancelled'
			 WHERE p.status = 'pending' AND r.status = 'cancelled'"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	private static function materialise_observed_free_fees(): void {
		global $wpdb;

		$regs = $wpdb->prefix . 'conf_registrations';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの一度きりのマイグレーション。テーブル名は $wpdb->prefix 由来の定数で、ユーザー入力を含まない。
		$rows = $wpdb->get_results(
			"SELECT DISTINCT event_id, fee_type, participation_type
			   FROM {$regs}
			  WHERE ( amount + tax ) = 0
			    AND fee_type <> ''
			    AND participation_type <> ''",
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		$by_event = [];
		foreach ( $rows as $row ) {
			$by_event[ (int) $row['event_id'] ][] = [ (string) $row['fee_type'], (string) $row['participation_type'] ];
		}

		foreach ( self::observed_group_staff_fees() as $row ) {
			$by_event[ (int) $row['event_id'] ][] = [ (string) $row['fee_type'], (string) $row['participation_type'] ];
		}

		if ( empty( $by_event ) ) {
			return;
		}

		$events = $wpdb->prefix . 'conf_events';

		foreach ( $by_event as $event_id => $pairs ) {
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: 上に同じ。値は prepare() で bind する。
			$event = $wpdb->get_row(
				$wpdb->prepare( "SELECT id, fee_rules_json, fee_rules_day_json, advance_fee_deadline FROM {$events} WHERE id = %d", $event_id ),
				ARRAY_A
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( null === $event ) {
				continue;
			}

			$update = [];

			$rules = json_decode( (string) $event['fee_rules_json'], true );
			$rules = is_array( $rules ) ? $rules : [];
			// 🔴 丸ごと空の料金表には書かない（無料イベントを壊す）
			if ( ! empty( array_diff_key( $rules, [ '_labels' => 1 ] ) ) ) {
				$changed = self::add_zero_cells( $rules, $pairs );
				if ( $changed ) {
					$update['fee_rules_json'] = wp_json_encode( $rules, JSON_UNESCAPED_UNICODE );
				}
			}

			$day = json_decode( (string) $event['fee_rules_day_json'], true );
			$day = is_array( $day ) ? $day : [];
			// 🔴 空の当日表には書かない（二段階価格が有効化される）
			if ( ! empty( $event['advance_fee_deadline'] ) && ! empty( array_diff_key( $day, [ '_labels' => 1 ] ) ) ) {
				$changed = self::add_zero_cells( $day, $pairs );
				if ( $changed ) {
					$update['fee_rules_day_json'] = wp_json_encode( $day, JSON_UNESCAPED_UNICODE );
				}
			}

			if ( ! empty( $update ) ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration on a custom table.
				$wpdb->update( $events, $update, [ 'id' => $event_id ] );
			}
		}
	}

	/**
	 * 団体スタッフが実際に使ってきた（区分 × 参加形態）を返す（v3.7.0 の移行用）
	 *
	 * 🔴 **スタッフは `conf_registrations` に行を作らない。**
	 * 区分は `conf_group_members.fee_type` にあり、代表者の `amount` は団体の合計なので、
	 * 申込側の `( amount + tax ) = 0` では**1 件も拾えない**。拾わないと、
	 * **既存の団体に同じ区分のスタッフを追加できなくなる**（`add_staff()` が
	 * `fee_is_configured()` で断るようになったため）。
	 *
	 * スタッフには金額の列が無く、料金は毎回 `resolve_fee_rules()` から引いていた
	 * （行が無ければ `?? 0` ＝ 無料）。**したがって「行が無いセル」に 0 を書くことは
	 * 過去の挙動をそのまま固定するだけ**で、値の入っているセルには触らない。
	 *
	 * 区分の決め方は `add_staff()` / `recalculate_group_amount()` と同じ順序:
	 * スタッフの `fee_type` → スタッフ会員の `member_type` → 代表者の `member_type` → `regular`。
	 *
	 * ⚠️ **`status = 'active'` のスタッフだけ**（外したスタッフのために区分を開けない）。
	 *
	 * @return array<int, array{event_id: int, fee_type: string, participation_type: string}>
	 */
	private static function observed_group_staff_fees(): array {
		global $wpdb;

		$staff   = $wpdb->prefix . 'conf_group_members';
		$regs    = $wpdb->prefix . 'conf_registrations';
		$members = $wpdb->prefix . 'conf_members';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの一度きりのマイグレーション。テーブル名は $wpdb->prefix 由来の定数で、ユーザー入力を含まない。
		$rows = $wpdb->get_results(
			"SELECT DISTINCT r.event_id AS event_id,
			        COALESCE( NULLIF( g.fee_type, '' ), sm.member_type, rm.member_type, 'regular' ) AS fee_type,
			        g.participation_type AS participation_type
			   FROM {$staff} g
			   INNER JOIN {$regs} r ON r.id = g.registration_id
			   LEFT JOIN {$members} sm ON sm.id = g.member_id
			   LEFT JOIN {$members} rm ON rm.id = r.member_id
			  WHERE g.status = 'active'
			    AND g.participation_type <> ''",
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return is_array( $rows ) ? $rows : [];
	}

	/**
	 * 料金表に「無かったセルだけ」0 を足す（上書きはしない）
	 *
	 * @param array $rules 料金表（参照で書き換える）
	 * @param array $pairs [ [ 区分, 参加形態 ], ... ]
	 * @return bool 1 つでも足したか
	 */
	private static function add_zero_cells( array &$rules, array $pairs ): bool {
		$changed = false;

		foreach ( $pairs as list( $type_key, $participation ) ) {
			if ( '_labels' === $type_key ) {
				continue;
			}
			if ( ! isset( $rules[ $type_key ] ) || ! is_array( $rules[ $type_key ] ) ) {
				$rules[ $type_key ] = [];
			}
			if ( ! array_key_exists( $participation, $rules[ $type_key ] ) ) {
				$rules[ $type_key ][ $participation ] = 0;
				$changed = true;
			}
		}

		return $changed;
	}

	/**
	 * uploads 配下に書き出された請求書・領収書 PDF を消す（v3.6.0）
	 *
	 * 🔴 **なぜ消すか**: これらは `uploads/conf-manager/invoices/INV-202608-00001.pdf`
	 * のように**連番のファイル名で公開ディレクトリに置かれていた**。氏名・所属・金額が
	 * 載る書類なので、番号を数えるだけで第三者に読まれる。帳票は要求のたびに DB から
	 * 描画する方式へ変えたため、ファイルはもう要らない。
	 *
	 * ⚠️ **DB が指しているパスだけを消す。** 事務局が同じフォルダへ手で置いた控えが
	 * 実在しうるので、ディレクトリごと消したり glob で舐めたりしない。
	 *
	 * ⚠️ 消えても帳票は失われない（`render_binary()` が DB から描き直す）。
	 * pdf_path 列は NOT NULL なので空にはせず、値はそのまま残す
	 * （どのファイル名で配っていたかは監査上の手がかりになる）。
	 */
	private static function remove_public_document_files(): void {
		global $wpdb;

		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return;
		}
		$base_dir = $upload_dir['basedir'] . '/conf-manager/';

		foreach ( [ 'conf_invoices', 'conf_receipts' ] as $table_suffix ) {
			$table = $wpdb->prefix . $table_suffix;

			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの一度きりのマイグレーション。テーブル名は $wpdb->prefix 由来の定数で、ユーザー入力を含まない。
			$paths = $wpdb->get_col( "SELECT pdf_path FROM {$table} WHERE pdf_path != ''" );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

			foreach ( (array) $paths as $path ) {
				$path = ltrim( (string) $path, '/' );

				// ⚠️ `..` を含む値が入っていたら触らない（DB の値をそのまま連結しない）。
				if ( '' === $path || false !== strpos( $path, '..' ) ) {
					continue;
				}

				$full = $base_dir . $path;
				if ( is_file( $full ) ) {
					wp_delete_file( $full );
				}
			}
		}

		self::protect_document_directories();
	}

	/**
	 * 帳票の置き場だけを直接アクセスから守る（多層防御）
	 *
	 * 📌 **これは保険。** 本体の対策は「ファイルを置かない」こと。
	 * `.htaccess` は Apache でしか効かない（本番の Xserver は Apache だが、
	 * WP.org 配布なので nginx の環境もある）ので、これを保護と数えない。
	 *
	 * ⚠️ **`conf-manager/` 全体を拒否しないこと。** 同じ配下には
	 * `fonts/`（PDF 生成が読む）や `templates/`（イベント資料）が同居しており、
	 * まとめて塞ぐと関係の無い機能を壊す。守るのは帳票の 2 つだけ。
	 */
	private static function protect_document_directories(): void {
		$upload_dir = wp_upload_dir();
		if ( ! empty( $upload_dir['error'] ) ) {
			return;
		}

		foreach ( [ 'invoices', 'receipts' ] as $sub ) {
			$dir = $upload_dir['basedir'] . '/conf-manager/' . $sub;
			if ( ! is_dir( $dir ) ) {
				continue;
			}

			$htaccess = $dir . '/.htaccess';
			if ( ! file_exists( $htaccess ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Local uploads dir; WP_Filesystem is not initialised during activation.
				@file_put_contents( $htaccess, "Deny from all\n" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort hardening; failure is not fatal.
			}

			$index = $dir . '/index.php';
			if ( ! file_exists( $index ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Local uploads dir; WP_Filesystem is not initialised during activation.
				@file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort hardening; failure is not fatal.
			}
		}
	}

	/**
	 * 論理削除済みの会員から UNIQUE キーを退避させる（v3.5.0）
	 *
	 * 既に削除されている行が email_hash / member_no を持ったままだと、
	 * **そのアドレスと会員番号はサイト内で永久に再利用できない**。
	 * アプリ側（email_exists()）は status='deleted' を除外して「重複なし」と答えるのに、
	 * INSERT だけが DB の UNIQUE で拒否され、画面には理由が出ない。
	 * 更新時に一度だけ遡って退避させる。
	 *
	 * 冪等: 既に `deleted-` へ置き換わっている行は対象外。この条件を落とすと
	 * 2 回目の移行で退避値が印に上書きされ、元のハッシュが失われる。
	 * 元の値は deleted_email_hash / deleted_member_no に残るので手作業で戻せる
	 * （削除そのものは取り消していない）。
	 *
	 * 🔴 WHERE status = 'deleted' を落とすと**全会員のメールが消し飛ぶ**。
	 */
	private static function tombstone_deleted_member_keys(): void {
		global $wpdb;

		$table = $wpdb->prefix . 'conf_members';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの一度きりのマイグレーション。テーブル名は $wpdb->prefix 由来の定数で、ユーザー入力を含まない。
		$wpdb->query(
			"UPDATE {$table}
			 SET deleted_email_hash = email_hash,
			     deleted_member_no  = member_no,
			     email_hash         = CONCAT('deleted-', id),
			     member_no          = NULL
			 WHERE status = 'deleted'
			   AND email_hash NOT LIKE 'deleted-%'"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * 消込済み決済の実入金額（paid_amount）を請求額で確定させる（v3.2.0）
	 *
	 * paid_amount 導入より前に消込された決済は実入金額を持たず、
	 * 表示・集計では「請求額どおり入金された」とみなして請求額で代替していた。
	 * しかしこの代替は請求額を参照するため、参加区分の訂正で請求額が変わると
	 * 実入金額まで一緒に動いてしまい、差額が常に 0 となって精算が
	 * 検出できなくなる。訂正が行われる前に、その時点の請求額を実入金額として
	 * データに焼き付けておく。
	 */
	private static function backfill_paid_amount(): void {
		global $wpdb;

		$table = $wpdb->prefix . 'conf_payments';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの一度きりのマイグレーション。テーブル名は $wpdb->prefix 由来の定数で、ユーザー入力を含まない。
		$wpdb->query(
			"UPDATE {$table}
			 SET paid_amount = amount + tax
			 WHERE paid_amount IS NULL
			   AND status IN ('completed', 'refunded', 'partial_refund')"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * 精算明細より前に行われた返金を明細へ移す（v3.3.0）
	 *
	 * 精算明細（payment_adjustments）は v3.2.0 で導入したが、それ以前の返金は
	 * payments.refund_amount という単一スロットにしか記録されていない。
	 * 純入金額を求める get_net_paid() は明細だけを合計するため、移行しないままだと
	 * 旧返金が無かったことになり、純入金額が返金額のぶん過大となって
	 * 精算状態が「精算済み」と誤判定される。
	 *
	 * 対象の絞り込みは「refund_amount が正なのに返金明細が 1 件も無い」行。
	 * v3.2.0 以降の返金は必ず明細を作ってから refund_amount をその合計で
	 * 上書きするので、この条件に当たるのは旧実装で返金した行に限られる。
	 * また移行後は条件が成立しなくなるため、何度実行しても二重には積まれない。
	 *
	 * 実施日時・担当者は当時の記録が残っていないため、日時は決済の日付で代用し、
	 * 担当者は空のままとする。理由に移行由来である旨を残す。
	 *
	 * 全額返金（status = refunded）は refund_amount ではなく paid_amount を積む。
	 * 旧実装は全額返金の基準を amount（税抜）に取っており、税込で受け取った額との
	 * 差（消費税分）が refund_amount に現れない。全額返金とは「受け取った額を
	 * すべて返した」ことなので、現行実装なら純入金額（＝ paid_amount）を記録する。
	 * refund_amount をそのまま移すと旧実装の取りこぼしを台帳へ持ち込み、
	 * 純入金額に税額分の端数が残り続ける。
	 */
	private static function migrate_legacy_refunds_to_ledger(): void {
		global $wpdb;

		$payments    = $wpdb->prefix . 'conf_payments';
		$adjustments = $wpdb->prefix . 'conf_payment_adjustments';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: カスタムテーブルへの一度きりのマイグレーション。テーブル名は $wpdb->prefix 由来の定数で、ユーザー入力を含まない。
		$rows = $wpdb->get_results(
			"SELECT p.id, p.registration_id, p.method, p.status, p.paid_amount,
					p.refund_amount, p.refund_reason, p.paid_at, p.updated_at, p.created_at
			 FROM {$payments} p
			 LEFT JOIN {$adjustments} a
				 ON a.payment_id = p.id AND a.type = 'refund'
			 WHERE p.refund_amount > 0
			   AND a.id IS NULL",
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( empty( $rows ) ) {
			return;
		}

		$valid_methods = PaymentAdjustmentManager::valid_methods();
		$marker        = __( 'Migrated from the refund amount recorded before settlement records were introduced.', 'conferencemanager' );

		foreach ( $rows as $row ) {
			$amount = (int) $row['refund_amount'];
			if ( 'refunded' === $row['status'] && null !== $row['paid_amount'] ) {
				$amount = (int) $row['paid_amount'];
			}
			if ( $amount <= 0 ) {
				continue;
			}

			$method = (string) $row['method'];
			if ( ! in_array( $method, $valid_methods, true ) ) {
				$method = 'bank';
			}

			$occurred_at = $row['paid_at'] ?? $row['updated_at'] ?? $row['created_at'];

			$reason = (string) ( $row['refund_reason'] ?? '' );
			$reason = '' === $reason ? $marker : $marker . ' / ' . $reason;

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
			$wpdb->insert(
				$adjustments,
				[
					'payment_id'      => (int) $row['id'],
					'registration_id' => (int) $row['registration_id'],
					'type'            => PaymentAdjustmentManager::TYPE_REFUND,
					'amount'          => $amount,
					'method'          => $method,
					'occurred_at'     => $occurred_at,
					'handled_by'      => null,
					'reason'          => $reason,
				]
			);
		}
	}

	/**
	 * 全テーブルを作成（dbDelta で差分適用）
	 */
	private static function create_tables(): void {
		global $wpdb;
		$charset_collate = $wpdb->get_charset_collate();
		$prefix          = $wpdb->prefix . 'conf_';

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		// 1. members（会員マスタ）
		//
		// deleted_email_hash / deleted_member_no（v3.5.0 で追加）＝ 論理削除時の退避先。
		// email_hash と member_no は UNIQUE KEY なので、論理削除した行が値を持ったままだと
		// **同じメールアドレス・会員番号で二度と登録できない**。削除・却下の時点で
		// 元の値をここへ逃がし、本体は印（deleted-<id>）と NULL に置き換える。
		// ⚠️ この 2 列に UNIQUE を付けないこと（同じアドレスを何度も登録・削除すると値が並ぶ）。
		dbDelta( "CREATE TABLE {$prefix}members (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			wp_user_id bigint(20) unsigned DEFAULT NULL,
			member_no varchar(30) DEFAULT NULL,
			member_type varchar(20) NOT NULL DEFAULT 'regular',
			is_member tinyint(1) NOT NULL DEFAULT 1,
			name_sei varchar(100) NOT NULL,
			name_mei varchar(100) NOT NULL,
			name_sei_kana varchar(100) DEFAULT NULL,
			name_mei_kana varchar(100) DEFAULT NULL,
			email varchar(500) NOT NULL,
			email_hash varchar(64) NOT NULL,
			phone varchar(500) DEFAULT NULL,
			org_name varchar(200) DEFAULT NULL,
			org_dept varchar(200) DEFAULT NULL,
			org_address varchar(500) DEFAULT NULL,
			passkey_credential_ids text DEFAULT NULL,
			email_verified tinyint(1) NOT NULL DEFAULT 0,
			registered_via varchar(20) NOT NULL DEFAULT 'admin',
			approval_status varchar(20) NOT NULL DEFAULT 'auto_approved',
			email_opt_out tinyint(1) NOT NULL DEFAULT 0,
			email_opt_out_at datetime DEFAULT NULL,
			status varchar(20) NOT NULL DEFAULT 'active',
			deleted_email_hash varchar(64) DEFAULT NULL,
			deleted_member_no varchar(30) DEFAULT NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY email_hash (email_hash),
			UNIQUE KEY member_no (member_no),
			KEY wp_user_id (wp_user_id),
			KEY member_type (member_type),
			KEY is_member (is_member),
			KEY status (status)
		) $charset_collate;" );

		// 2. events（イベントマスタ）
		dbDelta( "CREATE TABLE {$prefix}events (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			title varchar(300) NOT NULL,
			description longtext DEFAULT NULL,
			date_start datetime NOT NULL,
			date_end datetime NOT NULL,
			venue varchar(300) DEFAULT NULL,
			venue_address varchar(500) DEFAULT NULL,
			capacity int unsigned DEFAULT NULL,
			reg_start datetime DEFAULT NULL,
			reg_end datetime DEFAULT NULL,
			fee_rules_json longtext DEFAULT NULL,
			advance_fee_deadline datetime DEFAULT NULL,
			fee_rules_day_json longtext DEFAULT NULL,
			advance_fee_notice text DEFAULT NULL,
			tax_rate decimal(4,1) NOT NULL DEFAULT 10.0,
			submission_start datetime DEFAULT NULL,
			submission_end datetime DEFAULT NULL,
			submission_settings_json longtext DEFAULT NULL,
			payment_account_name varchar(200) DEFAULT NULL,
			payment_account_info text DEFAULT NULL,
			organizer_info text DEFAULT NULL,
			status varchar(20) NOT NULL DEFAULT 'draft',
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY status (status),
			KEY date_start (date_start),
			KEY reg_start (reg_start),
			KEY reg_end (reg_end)
		) $charset_collate;" );

		// 3. sessions（セッション）
		dbDelta( "CREATE TABLE {$prefix}sessions (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			event_id bigint(20) unsigned NOT NULL,
			title varchar(300) NOT NULL,
			session_type varchar(30) NOT NULL DEFAULT 'main',
			date_start datetime NOT NULL,
			date_end datetime NOT NULL,
			capacity int unsigned DEFAULT NULL,
			fee decimal(10,0) NOT NULL DEFAULT 0,
			sort_order int NOT NULL DEFAULT 0,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY event_id (event_id),
			KEY session_type (session_type),
			KEY event_id_sort (event_id, sort_order)
		) $charset_collate;" );

		// 4. registrations（参加申込）
		dbDelta( "CREATE TABLE {$prefix}registrations (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			registration_no varchar(30) NOT NULL,
			event_id bigint(20) unsigned NOT NULL,
			member_id bigint(20) unsigned NOT NULL,
			group_id varchar(30) DEFAULT NULL,
			is_representative tinyint(1) NOT NULL DEFAULT 0,
			participation_type varchar(20) NOT NULL DEFAULT 'onsite',
			payment_method varchar(20) NOT NULL DEFAULT 'bank',
			transfer_note varchar(255) DEFAULT NULL,
			session_options_json longtext DEFAULT NULL,
			amount decimal(10,0) NOT NULL DEFAULT 0,
			tax decimal(10,0) NOT NULL DEFAULT 0,
			fee_type varchar(50) DEFAULT NULL,
			fee_type_label varchar(100) DEFAULT NULL,
			discount_code varchar(50) DEFAULT NULL,
			status varchar(20) NOT NULL DEFAULT 'pending',
			registered_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			paid_at datetime DEFAULT NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY registration_no (registration_no),
			KEY event_id (event_id),
			KEY member_id (member_id),
			KEY group_id (group_id),
			KEY status (status),
			KEY payment_method (payment_method),
			KEY event_id_status (event_id, status),
			KEY member_id_status (member_id, status)
		) $charset_collate;" );

		// 5. group_members（団体スタッフ）
		dbDelta( "CREATE TABLE {$prefix}group_members (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			registration_id bigint(20) unsigned NOT NULL,
			member_id bigint(20) unsigned DEFAULT NULL,
			name varchar(200) NOT NULL,
			email varchar(500) NOT NULL,
			email_hash varchar(64) DEFAULT NULL,
			fee_type varchar(30) DEFAULT NULL,
			participation_type varchar(20) NOT NULL DEFAULT 'onsite',
			session_options_json longtext DEFAULT NULL,
			status varchar(20) NOT NULL DEFAULT 'active',
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY registration_id (registration_id),
			KEY member_id (member_id),
			KEY email_hash (email_hash)
		) $charset_collate;" );

		// 6. payments（決済記録）
		dbDelta( "CREATE TABLE {$prefix}payments (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			registration_id bigint(20) unsigned NOT NULL,
			method varchar(20) NOT NULL,
			amount decimal(10,0) NOT NULL,
			tax decimal(10,0) NOT NULL DEFAULT 0,
			fee decimal(10,0) NOT NULL DEFAULT 0,
			gateway_txn_id varchar(200) DEFAULT NULL,
			bank_transfer_name varchar(200) DEFAULT NULL,
			bank_transfer_date date DEFAULT NULL,
			is_group_payment tinyint(1) NOT NULL DEFAULT 0,
			status varchar(20) NOT NULL DEFAULT 'pending',
			paid_amount decimal(10,0) DEFAULT NULL,
			confirm_note text DEFAULT NULL,
			refund_amount decimal(10,0) DEFAULT NULL,
			refund_reason text DEFAULT NULL,
			paid_at datetime DEFAULT NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY registration_id (registration_id),
			KEY method (method),
			KEY status (status),
			KEY gateway_txn_id (gateway_txn_id),
			KEY paid_at (paid_at),
			KEY registration_id_status (registration_id, status)
		) $charset_collate;" );

		// 6-b. payment_adjustments（精算明細：窓口返金・追加徴収）
		// 区分の誤り等で請求額と入金額に差が出た場合の、返金／追加徴収を明細として記録する。
		// 純入金額 = payments.paid_amount − Σrefund + Σsurcharge
		dbDelta( "CREATE TABLE {$prefix}payment_adjustments (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			payment_id bigint(20) unsigned NOT NULL,
			registration_id bigint(20) unsigned NOT NULL,
			type varchar(20) NOT NULL,
			amount decimal(10,0) NOT NULL,
			method varchar(20) NOT NULL,
			occurred_at datetime NOT NULL,
			handled_by bigint(20) unsigned DEFAULT NULL,
			reason text DEFAULT NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			deleted_at datetime DEFAULT NULL,
			deleted_by bigint(20) unsigned DEFAULT NULL,
			PRIMARY KEY  (id),
			KEY payment_id (payment_id),
			KEY registration_id (registration_id),
			KEY type (type),
			KEY occurred_at (occurred_at),
			KEY deleted_at (deleted_at)
		) $charset_collate;" );

		// 7. invoices（請求書）
		dbDelta( "CREATE TABLE {$prefix}invoices (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			payment_id bigint(20) unsigned NOT NULL,
			invoice_no varchar(30) NOT NULL,
			addressee_name varchar(200) NOT NULL,
			addressee_dept varchar(200) DEFAULT NULL,
			honorific varchar(10) NOT NULL DEFAULT '御中',
			tax_registration_no varchar(30) DEFAULT NULL,
			pdf_path varchar(500) NOT NULL,
			issued_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY invoice_no (invoice_no),
			KEY payment_id (payment_id)
		) $charset_collate;" );

		// 8. receipts（領収書）
		dbDelta( "CREATE TABLE {$prefix}receipts (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			payment_id bigint(20) unsigned NOT NULL,
			receipt_no varchar(30) NOT NULL,
			addressee_name varchar(200) NOT NULL,
			addressee_dept varchar(200) DEFAULT NULL,
			proviso varchar(200) NOT NULL DEFAULT '参加費として',
			tax_registration_no varchar(50) DEFAULT NULL,
			pdf_path varchar(500) NOT NULL,
			issued_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			UNIQUE KEY receipt_no (receipt_no),
			KEY payment_id (payment_id)
		) $charset_collate;" );


		// 16. security_keys（暗号化キー）
		dbDelta( "CREATE TABLE {$prefix}security_keys (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			key_hash varchar(64) NOT NULL,
			purpose varchar(30) NOT NULL,
			encrypted_key text NOT NULL,
			active_from datetime NOT NULL,
			active_to datetime DEFAULT NULL,
			rotated_at datetime DEFAULT NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY key_hash (key_hash),
			KEY purpose (purpose),
			KEY active_from (active_from)
		) $charset_collate;" );

		// 17. audit_log（監査ログ）
		dbDelta( "CREATE TABLE {$prefix}audit_log (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			user_id bigint(20) unsigned DEFAULT NULL,
			action varchar(50) NOT NULL,
			target_type varchar(50) DEFAULT NULL,
			target_id bigint(20) unsigned DEFAULT NULL,
			detail_json longtext DEFAULT NULL,
			ip_address varchar(45) DEFAULT NULL,
			user_agent varchar(500) DEFAULT NULL,
			created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY user_id (user_id),
			KEY action (action),
			KEY target_type (target_type),
			KEY created_at (created_at)
		) $charset_collate;" );

	}
}
