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

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


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

/**
 * 暗号化キーのローテーション管理
 *
 * wp_conf_security_keys テーブルでキー履歴を管理し、
 * ローテーション時に既存データを新キーで再暗号化する。
 *
 * @since 1.0.0
 */
class KeyRotation {

	use Singleton;
	use HasHooks;

	private const TABLE = 'conf_security_keys';

	/**
	 * 暗号化カラムの定義（テーブル名 => カラム名の配列）
	 */
	private const ENCRYPTED_COLUMNS = [
		'conf_members'       => [ 'email', 'phone', 'org_address' ],
		'conf_group_members' => [ 'email' ],
		'conf_coauthors'     => [ 'email' ],
		'conf_sponsors'      => [ 'contact_email', 'contact_phone' ],
		'conf_sponsor_staff' => [ 'email' ],
	];

	/**
	 * 現在有効な鍵のハッシュを控えておく wp_options キー
	 *
	 * 鍵そのものではなくハッシュだけを持つ。鍵が差し替わったことを
	 * 検知するための基準値で、これ自体からは鍵を復元できない。
	 */
	private const HASH_OPTION = 'conf_manager_encryption_key_hash';

	/**
	 * マスターキーを自動生成して保持する wp_options キー
	 *
	 * wp-config.php の定数が無い環境ではここが鍵の在り処になる。
	 * 案内文でこの名前を管理者に見せるため、リテラルを散らさない。
	 */
	private const KEY_OPTION = 'conf_manager_encryption_key';

	/**
	 * 「承知した」操作の admin-post アクション名
	 */
	private const ACK_ACTION = 'conf_manager_ack_key_change';

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

	protected function register_hooks(): void {
		$this->add_action( 'admin_post_' . self::ACK_ACTION, [ $this, 'handle_acknowledge' ] );
		$this->add_action( 'admin_notices', [ $this, 'maybe_show_key_change_notice' ] );
	}

	/**
	 * 現在の鍵のハッシュを基準値として記録する
	 *
	 * **既に基準値がある場合は上書きしない。** 上書きしてしまうと鍵が
	 * 差し替わったことを検知できなくなる（変更の証拠が消える）。
	 * 基準値の更新は「暗号化データが 1 件も無い」場合か、管理者が
	 * 明示的に承知した場合だけに限る。
	 */
	public function remember_current_key(): void {
		if ( '' !== (string) get_option( self::HASH_OPTION, '' ) ) {
			return;
		}
		$this->store_current_hash();
	}

	/**
	 * 現在の鍵のハッシュを基準値へ書き込む（無条件）
	 */
	private function store_current_hash(): void {
		try {
			$hash = Encryption::instance()->get_current_key_hash();
		} catch ( \Throwable $e ) {
			return;
		}
		update_option( self::HASH_OPTION, $hash, false );
	}

	/**
	 * 鍵が差し替わっているかを判定する
	 *
	 * @return bool 基準値があり、かつ現在の鍵と異なる場合に true
	 */
	public function is_key_changed(): bool {
		$stored = (string) get_option( self::HASH_OPTION, '' );
		if ( '' === $stored ) {
			return false;
		}
		try {
			return ! hash_equals( $stored, Encryption::instance()->get_current_key_hash() );
		} catch ( \Throwable $e ) {
			return false;
		}
	}

	/**
	 * 暗号化された値を 1 件でも保持しているか
	 *
	 * 鍵が変わっても、まだ暗号化データが無いサイトでは失うものが無い。
	 * その場合は警告を出さず基準値だけ更新する（無意味な警告を出さない）。
	 *
	 * @return bool
	 */
	public function has_encrypted_data(): bool {
		global $wpdb;

		foreach ( self::ENCRYPTED_COLUMNS as $table_suffix => $columns ) {
			$table = $wpdb->prefix . $table_suffix;

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
			$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
			if ( ! $table_exists ) {
				continue;
			}

			foreach ( $columns as $col ) {
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table and column names come from the plugin-defined whitelist above, never from input.
				$found = $wpdb->get_var(
					"SELECT 1 FROM {$table} WHERE {$col} IS NOT NULL AND {$col} <> '' LIMIT 1"
				);
				// phpcs:enable
				if ( $found ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * 鍵の差し替わりを検知して管理画面に警告を出す
	 *
	 * **再暗号化はしない。** 旧鍵は保存していないため（register_current_key()
	 * が encrypted_key を空で入れているとおり）、プラグイン側から復旧する術は
	 * 無い。ただしデータが壊れたわけではなく、旧鍵を戻せば読める。だから
	 * ここでできる最善は「早く、はっきり気づかせる」ことに尽きる。
	 */
	public function maybe_show_key_change_notice(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// 基準値が無い＝この版で初めて観測したサイト。黙って基準値を作る。
		if ( '' === (string) get_option( self::HASH_OPTION, '' ) ) {
			$this->store_current_hash();
			return;
		}

		if ( ! $this->is_key_changed() ) {
			return;
		}

		// 失うものが無いなら警告せず基準値だけ追従させる
		if ( ! $this->has_encrypted_data() ) {
			$this->store_current_hash();
			return;
		}

		echo '<div class="notice notice-error">';
		printf(
			'<p><strong>Conference Manager:</strong> %s</p>',
			esc_html__( 'The encryption key has changed. Personal data saved with the previous key (email addresses, phone numbers, addresses) cannot be read until that key is restored.', 'conferencemanager' )
		);
		printf( '<p>%s</p>', esc_html( $this->restore_instruction() ) );

		// 状態を変える操作なので GET リンクにはしない。管理画面のリンクは
		// ブラウザや拡張機能に先読みされることがあり、先読みでは nonce も一緒に
		// 送られて検証を通ってしまう。すると管理者が押していないのに基準値が
		// 現在の鍵に追いつき、**差し替わりの唯一の証拠である警告だけが消える**。
		printf(
			'<form method="post" action="%s"><input type="hidden" name="action" value="%s" />',
			esc_url( admin_url( 'admin-post.php' ) ),
			esc_attr( self::ACK_ACTION )
		);
		wp_nonce_field( self::ACK_ACTION );
		printf(
			'<p><button type="submit" class="button-link">%s</button></p></form>',
			esc_html__( 'I understand this data is no longer readable — stop showing this', 'conferencemanager' )
		);
		echo '</div>';
	}

	/**
	 * 鍵の戻し方の案内文を「旧鍵が今どこにあるか」に応じて返す
	 *
	 * 鍵は wp-config.php の定数とは限らない（定数が無ければ wp_options の
	 * conf_manager_encryption_key を使う）。実際の在り処と違う場所を案内すると、
	 * 直せるはずの管理者が直せなくなる。
	 *
	 * さらに「今どこから読んでいるか」だけでは足りない。最も起きやすいのは
	 * **DB の自動生成キーで運用していたサイトに、後から定数を足した**場合で、
	 * このとき取得元は 'config' だが旧鍵は wp_options に残ったままになる
	 * （定数があると get_master_key() も Activator::ensure_encryption_key() も
	 * 早期 return するだけで、この option を消しも上書きもしない）。
	 * 取得元だけで分岐すると、ワンステップで直る状況で管理者を
	 * wp-config.php へ送り返してしまう。
	 *
	 * @return string 案内文
	 */
	private function restore_instruction(): string {
		if ( $this->previous_key_is_in_database() ) {
			return __( 'The previous key is still stored in this site\'s database. Copy the value of the "conf_manager_encryption_key" option (in the wp_options table) into the CONF_MANAGER_ENCRYPTION_KEY constant in wp-config.php — or remove that constant — and the data becomes readable again.', 'conferencemanager' );
		}

		try {
			$source = Encryption::instance()->get_key_source();
		} catch ( \Throwable $e ) {
			$source = '';
		}

		if ( 'database' === $source ) {
			return __( 'This site reads the key from its database (the "conf_manager_encryption_key" option in the wp_options table), and that value has changed. The key is generated automatically, so if the previous value was lost it can only be recovered from a database backup. The data itself is intact — it only becomes readable again once the original key is back in place.', 'conferencemanager' );
		}

		return __( 'Restore the previous key in wp-config.php as CONF_MANAGER_ENCRYPTION_KEY. The data itself is intact — it only becomes readable again once the original key is back in place.', 'conferencemanager' );
	}

	/**
	 * 基準値の鍵（＝データを暗号化した鍵）が wp_options に残っているか
	 *
	 * ハッシュ同士で照合するので、**鍵そのものを読み出して比較しない**。
	 * 「たぶん残っている」ではなく一致した場合だけ true を返すので、
	 * これを根拠に出す案内は確実に直る案内になる。
	 *
	 * @return bool
	 */
	private function previous_key_is_in_database(): bool {
		$baseline = (string) get_option( self::HASH_OPTION, '' );
		if ( '' === $baseline ) {
			return false;
		}

		$db_key = (string) get_option( self::KEY_OPTION, '' );
		if ( strlen( $db_key ) < 16 ) {
			return false;
		}

		return hash_equals( $baseline, Encryption::hash_of_master_key( $db_key ) );
	}

	/**
	 * 「承知した」の処理（admin-post.php 経由の POST のみ）
	 *
	 * 承知した時点の鍵を新しい基準値として確定させる。基準値が現在の鍵に
	 * 追いつくので、以後この警告は出ない。
	 *
	 * 別途「承知した」フラグは持たない。フラグを持つと、承知したあとに
	 * **旧鍵を戻した**場合（＝正しく警告すべき場面）まで抑制してしまうため。
	 * 基準値の更新だけで十分かつ安全。
	 */
	public function handle_acknowledge(): void {
		// **admin-post.php は $_REQUEST['action'] だけを見てここへ来る＝GET でも届く。**
		// フォームを POST にしても入口は塞がっていない。URL を踏むだけで基準値が
		// 現在の鍵へ追いつき、鍵が差し替わった唯一の証拠が消えてしまうので、
		// ここで方式そのものを見て拒む。
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 方式の判定のみ。nonce は下で check_admin_referer() が検証する。
		// $_SERVER も入力なので素通しにしない（CLAUDE.md「$_SERVER 直接参照禁止」）。
		// リテラル 'POST' と比べるだけで注入余地は無いが、Plugin Check は
		// MissingUnslash / InputNotSanitized を WARNING で出す。
		$request_method = isset( $_SERVER['REQUEST_METHOD'] )
			? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
			: '';
		if ( 'POST' !== $request_method ) {
			wp_die(
				esc_html__( 'This action must be submitted with the button in the warning.', 'conferencemanager' ),
				'',
				[ 'response' => 405 ]
			);
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die(
				esc_html__( 'You do not have permission to do this.', 'conferencemanager' ),
				'',
				[ 'response' => 403 ]
			);
		}

		check_admin_referer( self::ACK_ACTION );

		$this->store_current_hash();

		wp_safe_redirect( admin_url( 'admin.php?page=conferencemanager' ) );
		exit;
	}

	/**
	 * 現在のキーをセキュリティキーテーブルに登録する
	 *
	 * プラグイン有効化時や初回起動時に呼び出す。
	 */
	public function register_current_key(): void {
		global $wpdb;

		$encryption = Encryption::instance();
		$key_hash   = $encryption->get_current_key_hash();
		$table      = $wpdb->prefix . self::TABLE;

		// 既に登録済みか確認
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$exists = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE key_hash = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from safe internal method.
				$key_hash
			)
		);

		if ( $exists > 0 ) {
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		$wpdb->insert(
			$table,
			[
				'key_hash'      => $key_hash,
				'purpose'       => 'data_encryption',
				'encrypted_key' => '', // マスターキー由来のため保存不要
				'active_from'   => current_time( 'mysql', true ),
				'active_to'     => null,
			],
			[ '%s', '%s', '%s', '%s', null ]
		);
	}

	/**
	 * キーローテーションを実行する
	 *
	 * 1. 旧キーのactive_toを設定
	 * 2. 新キーを登録
	 * 3. 全暗号化データを新キーで再暗号化
	 *
	 * @param string $old_master_key 旧マスターキー（平文）
	 * @return array{re_encrypted: int, errors: int} 処理結果
	 */
	public function rotate( string $old_master_key ): array {
		global $wpdb;

		$table      = $wpdb->prefix . self::TABLE;
		$encryption = Encryption::instance();

		// 旧キーを導出
		$old_derived = Encryption::derive_key( $old_master_key );
		$old_hash    = hash( 'sha256', $old_derived );

		// 旧キーのactive_toを設定
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
		$wpdb->update(
			$table,
			[
				'active_to'  => current_time( 'mysql', true ),
				'rotated_at' => current_time( 'mysql', true ),
			],
			[ 'key_hash' => $old_hash ],
			[ '%s', '%s' ],
			[ '%s' ]
		);

		// 新キーを登録
		$this->register_current_key();

		// 全暗号化データを再暗号化
		$result = $this->re_encrypt_all_data( $old_derived, $encryption );

		// 取りこぼしが無かったときだけ基準値を新しい鍵へ進める。
		//
		// 進めないと、**正常にローテーションを終えた直後に警告が出る**。
		// データは新しい鍵で読めるのに基準値だけ旧鍵を指したままになるため、
		// 「そのキーを戻すまで読み取れません」という誤った案内が出てしまい、
		// 管理者が正常なデータに対して復旧作業を始めかねない。
		//
		// 逆に errors > 0 のときに進めてはいけない。一部の行が旧鍵のまま
		// 残っているのに基準値だけ追いつくと、**取りこぼしに気づく最後の
		// 機会が消える**（status 列の件と同じ形の事故になる）。
		if ( 0 === $result['errors'] ) {
			$this->store_current_hash();
		}

		return $result;
	}

	/**
	 * 全暗号化データを新キーで再暗号化
	 *
	 * @param string     $old_key    旧導出済みキー（32バイトバイナリ）
	 * @param Encryption $encryption 新キーのEncryptionインスタンス
	 * @return array{re_encrypted: int, errors: int}
	 */
	private function re_encrypt_all_data( string $old_key, Encryption $encryption ): array {
		global $wpdb;

		$re_encrypted = 0;
		$errors       = 0;

		foreach ( self::ENCRYPTED_COLUMNS as $table_suffix => $columns ) {
			$table = $wpdb->prefix . $table_suffix;

			// テーブルが存在するか確認
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
			$table_exists = $wpdb->get_var(
				$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
			);
			if ( ! $table_exists ) {
				continue;
			}

			// status 列は全テーブルにあるわけではない（例: conf_coauthors には無い）。
			// 無いテーブルに WHERE status を付けると SQL が失敗して空が返り、
			// **そのテーブルだけ再暗号化されないまま「成功」になる**。
			// 旧鍵を捨てた後で気づく類の事故なので、列の有無を実際に見て分岐する。
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from plugin-defined whitelist.
			$has_status = (bool) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'status' ) );
			$where_sql  = $has_status ? "WHERE status != 'deleted'" : '';

			// 全レコードを取得（バッチ処理）
			//
			// **OFFSET で頁を送ってはいけない。** このループは読みながら同じ行を
			// UPDATE していくので、順序が保証されないと頁をまたいだときに
			// 行を飛ばす＝**その行だけ再暗号化されない**（旧鍵を捨てた後に
			// しか気づけない。status 列の件とまったく同じ形の事故）。
			// 主キーで進む keyset 方式なら、順序にも途中の更新にも依存しない。
			//
			// $where_sql は status 列があるときだけ入るので、空のときに AND を
			// 付けない分岐が要る。ここを誤ると status 列の無いテーブルで SQL が
			// 失敗し、そのテーブルが丸ごと飛ぶ（直したはずの穴が戻る）。
			$batch_size = 100;
			$last_id    = 0;
			$seek_sql   = '' === $where_sql ? 'WHERE id > %d' : $where_sql . ' AND id > %d';

			while ( true ) {
				// ReplacementsWrongNumber は静的解析の取り違え。$seek_sql は
				// 'WHERE id > %d' / '... AND id > %d' のどちらかで、**自分で %d を 1 つ持つ**が、
				// 解析器は変数の中身を見ないのでリテラルの 'LIMIT %d' しか数えられず
				// 「placeholder 1 に対して引数 2」と読む。実行時は 2 対 2 で正しい
				// （$seek_sql の組み立ては上の $where_sql 分岐を参照）。
				// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name, column list and the status predicate all come from plugin-defined values; user values are bound via prepare().
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT id, " . implode( ', ', $columns ) . " FROM {$table} {$seek_sql} ORDER BY id ASC LIMIT %d",
						$last_id,
						$batch_size
					)
				);
				// phpcs:enable

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

				// 頁が進まなければ打ち切る安全弁。keyset は「id が必ず増える」ことに
				// 依存しており、進まなくなると**同じバッチを永久に読み続ける**。
				// 落ちるより先にハングする形は原因が分かりにくいので、ここで止める。
				$batch_start_id = $last_id;

				foreach ( $rows as $row ) {
					// 復号に失敗した行でも必ず進める（進めないと無限ループになる）
					$last_id     = max( $last_id, (int) $row->id );
					$update_data = [];

					foreach ( $columns as $col ) {
						$encrypted_value = $row->$col ?? '';
						if ( '' === $encrypted_value ) {
							continue;
						}

						try {
							// 旧キーで復号
							$plaintext = $encryption->decrypt_with_key( $encrypted_value, $old_key );
							// 新キーで暗号化
							$update_data[ $col ] = $encryption->encrypt( $plaintext );
						} catch ( \RuntimeException $e ) {
							$errors++;
							continue;
						}
					}

					if ( ! empty( $update_data ) ) {
						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; request-local write, no caching needed.
						$wpdb->update(
							$table,
							$update_data,
							[ 'id' => $row->id ],
							array_fill( 0, count( $update_data ), '%s' ),
							[ '%d' ]
						);
						$re_encrypted++;
					}
				}

				if ( $last_id === $batch_start_id ) {
					break;
				}
			}
		}

		return [
			're_encrypted' => $re_encrypted,
			'errors'       => $errors,
		];
	}

	/**
	 * キー履歴を取得
	 *
	 * @return array キー履歴の配列
	 */
	public function get_key_history(): array {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from plugin-defined prefix; no user values in this query.
		$history = $wpdb->get_results(
			"SELECT key_hash, purpose, active_from, active_to, rotated_at, created_at
			 FROM {$table}
			 WHERE purpose = 'data_encryption'
			 ORDER BY active_from DESC",
			ARRAY_A
		);
		// phpcs:enable
		return $history;
	}
}
