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

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


use ConfManager\Traits\Singleton;

/**
 * 監査ログ
 *
 * 全操作を wp_conf_audit_log テーブルに記録する。
 * IPアドレス・User-Agentも自動的に取得して保存。
 *
 * @since 1.0.0
 */
class AuditLog {

	use Singleton;

	private const TABLE = 'conf_audit_log';

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

	/**
	 * 監査ログを記録する
	 *
	 * @param string   $action      操作名（例: login, update_member, create_payment）
	 * @param string   $target_type 対象種別（例: member, registration, payment）
	 * @param int|null $target_id   対象レコードID
	 * @param array    $detail      操作詳細（JSON化される）
	 * @param int|null $user_id     操作者のWPユーザーID（null=自動取得）
	 */
	public function log(
		string $action,
		string $target_type = '',
		?int $target_id = null,
		array $detail = [],
		?int $user_id = null
	): void {
		global $wpdb;

		$table = $wpdb->prefix . self::TABLE;

		if ( null === $user_id ) {
			$user_id = get_current_user_id() ?: null;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table query.
		$wpdb->insert(
			$table,
			[
				'user_id'     => $user_id,
				'action'      => sanitize_text_field( $action ),
				'target_type' => '' !== $target_type ? sanitize_text_field( $target_type ) : null,
				'target_id'   => $target_id,
				'detail_json' => ! empty( $detail ) ? wp_json_encode( $detail, JSON_UNESCAPED_UNICODE ) : null,
				'ip_address'  => $this->get_client_ip(),
				'user_agent'  => $this->get_user_agent(),
			],
			[ '%d', '%s', '%s', '%d', '%s', '%s', '%s' ]
		);
	}

	/**
	 * ログを検索する
	 *
	 * @param array $args {
	 *     検索条件
	 *     @type string $action      操作名で絞り込み
	 *     @type string $target_type 対象種別で絞り込み
	 *     @type int    $target_id   対象IDで絞り込み
	 *     @type int    $user_id     操作者で絞り込み
	 *     @type string $date_from   開始日（YYYY-MM-DD）
	 *     @type string $date_to     終了日（YYYY-MM-DD）
	 *     @type int    $per_page    1ページあたりの件数（デフォルト: 50）
	 *     @type int    $page        ページ番号（デフォルト: 1）
	 * }
	 * @return array{items: array, total: int}
	 */
	public function search( array $args = [] ): array {
		global $wpdb;

		$table    = $wpdb->prefix . self::TABLE;
		$where    = [];
		$values   = [];
		$per_page = absint( $args['per_page'] ?? 50 );
		$page     = max( 1, absint( $args['page'] ?? 1 ) );

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

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

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

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

		if ( ! empty( $args['date_from'] ) ) {
			$where[]  = 'created_at >= %s';
			$values[] = sanitize_text_field( $args['date_from'] ) . ' 00:00:00';
		}

		if ( ! empty( $args['date_to'] ) ) {
			$where[]  = 'created_at <= %s';
			$values[] = sanitize_text_field( $args['date_to'] ) . ' 23:59:59';
		}

		$where_sql = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';
		$offset    = ( $page - 1 ) * $per_page;

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Hardcoded WP prefix table; $where_sql uses validated 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 );

		// データ取得
		$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 created_at DESC, id DESC 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
		$items          = $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

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

	/**
	 * 一般操作の保持日数
	 */
	public const RETENTION_DAYS_DEFAULT = 365;

	/**
	 * セキュリティ証跡の保持日数
	 *
	 * ⚠️ **一般操作より短くしてはならない。** 短くすると
	 * 「セキュリティ証跡だけ先に消える」という逆転が起きる。
	 * {@see self::purge()} で `max()` を掛けて担保している。
	 */
	public const RETENTION_DAYS_SECURITY = 1095;

	/**
	 * 一般操作の IP / User-Agent を落とすまでの日数
	 */
	public const ANONYMIZE_DAYS_DEFAULT = 90;

	/**
	 * 1 回の実行で触る最大行数
	 *
	 * 🔴 **本番は共用サーバー（Xserver）。** 初回の実行で数年ぶんを 1 文の DELETE に
	 * まとめると、実行時間の上限に当たる。InnoDB は途中で落ちると**丸ごと巻き戻る**ので、
	 * **1 行も減らないまま毎日失敗し続ける**（永久に収束しない）状態になりうる。
	 * 1 文を有界にして、日次 cron が少しずつ削る。
	 */
	public const BATCH_SIZE = 10000;

	/**
	 * 1 回の実行で回すバッチの数
	 *
	 * 🔴 **1 回 1 バッチだと滞留に追いつかない。** Pro の実測では、100 万行の滞留に
	 * **100 日**かかる（`handoff:notes/2026-08-27-pro-to-free-2.md` の F-設計）。
	 * その間ずっと表は大きいままで、**「毎日走っているのに一向に減らない」**という、
	 * 上限を入れて避けたかった見え方に近くなる。
	 *
	 * 1 トランザクションは `BATCH_SIZE` 行のままなので、**小さく保つ狙いは保たれる**。
	 * `1` にすれば 1 回 1 バッチと同じ＝上位互換。
	 */
	public const MAX_BATCHES_PER_RUN = 50;

	/**
	 * セキュリティ証跡とみなす action の前方一致
	 *
	 * ⚠️ **`_` は LIKE のワイルドカードなので `esc_like()` を必ず通すこと。**
	 */
	private const SECURITY_ACTION_PREFIXES = [
		'bot_guard_',
	];

	/**
	 * セキュリティ証跡とみなす action（完全一致）
	 *
	 * 🔴 **ここに載っていない action は一般操作＝短いほうの保持期間で消え、
	 * IP と User-Agent は 90 日で落ちる。**
	 * 認証・不正検知・権限に関わる action を新設したときは**必ずここへ足す**。
	 * 分類は `AuditLogRetentionTest` が固定している。
	 */
	private const SECURITY_ACTIONS = [
		// 認証（自己登録の OTP・パスキー）
		'self_register_otp_sent',
		'self_register_otp_verified',
		'self_register_existing_member',
		'passkey_registered',
		// 不正検知・レート制限
		'rate_limit_exceeded',
	];

	/**
	 * その action がセキュリティ証跡か
	 *
	 * @param string $action 操作名
	 * @return bool
	 */
	public static function is_security_action( string $action ): bool {
		foreach ( self::SECURITY_ACTION_PREFIXES as $prefix ) {
			if ( 0 === strpos( $action, $prefix ) ) {
				return true;
			}
		}

		return in_array( $action, self::SECURITY_ACTIONS, true );
	}

	/**
	 * 古いログを削除する（保持期間超過分）
	 *
	 * 🔴 **これが呼ばれるまで、監査ログを間引く経路は製品に 1 つも無かった。**
	 * 定義とテストからしか呼ばれておらず、`conf_audit_log` を減らす経路は
	 * `uninstall.php` 以外に存在しなかった＝**入れっぱなしで無制限に増えていた**。
	 *
	 * 🔴 **一般操作とセキュリティ証跡で保持期間を分ける。**
	 * 一律で切ると、不正検知やレート制限の証跡まで同時に消える。
	 *
	 * ⚠️ **DELETE は 2 本に分ける。** 1 本の SQL に OR で押し込むと、
	 * 「どちらの条件で消えたのか」がテストからも運用からも見えなくなる。
	 *
	 * @param int      $days          一般操作の保持日数
	 * @param int|null $security_days セキュリティ証跡の保持日数（null=既定）
	 * @return int 削除された件数（2 本の合計）
	 */
	public function purge( int $days = self::RETENTION_DAYS_DEFAULT, ?int $security_days = null ): int {
		global $wpdb;

		$table = $wpdb->prefix . self::TABLE;

		$security_days = $security_days ?? self::RETENTION_DAYS_SECURITY;
		// セキュリティ証跡が一般操作より先に消えることは無いようにする。
		$security_days = max( $days, $security_days );

		$cutoff          = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		$security_cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $security_days * DAY_IN_SECONDS ) );

		list( $security_expr, $security_params ) = $this->security_action_condition();
		$batch = $this->batch_size();

		// ⚠️ **SQL は呼び出し側にリテラルで置く。** 繰り返しの制御ごと切り出して
		//    `$wpdb->prepare()` に変数の SQL を渡すと `PreparedSQL.NotPrepared` が
		//    **ERROR** になり、WP.org の配布ゲート（ERROR 0）で止まる（Pro が踏んだ）。
		$deleted = $this->run_batched(
			function () use ( $table, $cutoff, $security_expr, $security_params, $batch ): int {
				global $wpdb;

				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: テーブル名は内部生成。$security_expr はクラス定数の件数だけプレースホルダを並べた文字列で、外部入力は 1 文字も入らない。値は全て prepare に渡している。ReplacementsWrongNumber は誤検知（array_merge の要素数を sniff が静的に数えられない）。
				return (int) $wpdb->query(
					$wpdb->prepare(
						"DELETE FROM {$table} WHERE created_at < %s AND NOT {$security_expr} LIMIT %d",
						array_merge( [ $cutoff ], $security_params, [ $batch ] )
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
			}
		);

		$deleted += $this->run_batched(
			function () use ( $table, $security_cutoff, $security_expr, $security_params, $batch ): int {
				global $wpdb;

				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: 上の DELETE と同じ。
				return (int) $wpdb->query(
					$wpdb->prepare(
						"DELETE FROM {$table} WHERE created_at < %s AND {$security_expr} LIMIT %d",
						array_merge( [ $security_cutoff ], $security_params, [ $batch ] )
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
			}
		);

		return $deleted;
	}

	/**
	 * 一般操作の行から、IP アドレスと User-Agent だけを落とす（匿名化）
	 *
	 * 🔴 **「誰が・いつ・何をしたか」は残し、個人データだけ持たない。**
	 * 間引き（{@see self::purge()}）だけでも「無期限」ではなくなるが、
	 * 一般操作の IP と User-Agent は**あとから役に立つ場面がほとんど無い**のに
	 * 保持期間いっぱい（既定 365 日）残る。運用者の方針は「個人情報は最小限」。
	 *
	 * ⚠️ **セキュリティ証跡は対象外。** 不正検知・レート制限・認証の行から IP を
	 * 落とすと、**それらを 3 年残している理由（あとから不正を追う）が失われる**。
	 *
	 * 🔴 **既に落ちている行を除く条件は、無いと機能が壊れる。**
	 * `LIMIT` が**先頭の匿名化済みの行を食い潰す**と、`affected_rows` は*変化した*行しか
	 * 数えないので **2 巡目は 0 が返り、まだ IP が残っている行に永久に到達しない**。
	 * ＝**回を重ねても進まない。** `AuditLogRetentionTest::test_anonymize_converges_over_repeated_runs`
	 * が固定している（この条件を外すと赤になることを実測済み）。
	 *
	 * ⚠️ **1 文で撃っていた頃は「観測できない条件」だった**（外しても戻り値が変わらないため）。
	 * **上限を足した時点で意味が変わったのに、以前の変異を当て直していなかった。**
	 * 指摘は Pro（`handoff:notes/2026-08-27-pro-to-free-2.md` の G-返信）。
	 * 📌 **「殺せない」は実装に対する判定であって、条件に対する判定ではない。**
	 *
	 * @param int $days この日数を過ぎた一般操作の行が対象
	 * @return int 落とした件数
	 */
	public function anonymize_old_rows( int $days = self::ANONYMIZE_DAYS_DEFAULT ): int {
		global $wpdb;

		$table  = $wpdb->prefix . self::TABLE;
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );

		list( $security_expr, $security_params ) = $this->security_action_condition();
		$batch = $this->batch_size();

		return $this->run_batched(
			function () use ( $table, $cutoff, $security_expr, $security_params, $batch ): int {
				global $wpdb;

				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reason: テーブル名は内部生成。$security_expr はクラス定数の件数だけプレースホルダを並べた文字列で、外部入力は 1 文字も入らない。値は全て prepare に渡している。ReplacementsWrongNumber は誤検知（array_merge の要素数を sniff が静的に数えられない）。
				return (int) $wpdb->query(
					$wpdb->prepare(
						"UPDATE {$table}
						 SET ip_address = NULL, user_agent = NULL
						 WHERE created_at < %s
						   AND NOT {$security_expr}
						   AND ( ip_address IS NOT NULL OR user_agent IS NOT NULL )
						 LIMIT %d",
						array_merge( [ $cutoff ], $security_params, [ $batch ] )
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
			}
		);
	}

	/**
	 * 上限つきの 1 文を、進まなくなるまで繰り返す
	 *
	 * 🔴 **1 トランザクションは小さいまま、1 回の実行では複数バッチ進む。**
	 * 1 回 1 バッチだと滞留に追いつかない（{@see self::MAX_BATCHES_PER_RUN}）。
	 *
	 * ⚠️ **SQL はここに置かない。** `$wpdb->prepare()` に変数の SQL を渡す形にすると
	 * `WordPress.DB.PreparedSQL.NotPrepared` が **ERROR** になり、WP.org の
	 * 配布ゲート（ERROR 0）で止まる。呼び出し側のクロージャにリテラルで置くこと。
	 *
	 * @param callable():int $one_batch 1 バッチ実行して、触れた行数を返す
	 * @return int 触れた行数の合計
	 */
	private function run_batched( callable $one_batch ): int {
		$total = 0;
		$max   = $this->max_batches_per_run();

		for ( $i = 0; $i < $max; $i++ ) {
			$n = (int) $one_batch();
			if ( $n < 1 ) {
				break;
			}
			$total += $n;
		}

		return $total;
	}

	/**
	 * 1 回の実行で回すバッチの数
	 *
	 * ⚠️ **0 以下を渡されても止まらないよう既定に倒す**（フィルタは第三者が書き換えられる）。
	 *
	 * @return int
	 */
	private function max_batches_per_run(): int {
		$max = (int) apply_filters( 'conf_manager_audit_log_max_batches_per_run', self::MAX_BATCHES_PER_RUN );

		return $max < 1 ? self::MAX_BATCHES_PER_RUN : $max;
	}

	/**
	 * 1 回の実行で触る最大行数
	 *
	 * ⚠️ **0 以下を渡されても 1 文で全件を触らない。**
	 * フィルタは第三者（テーマ・他プラグイン）が書き換えられる。
	 *
	 * @return int
	 */
	private function batch_size(): int {
		$batch = (int) apply_filters( 'conf_manager_audit_log_batch_size', self::BATCH_SIZE );

		return $batch < 1 ? self::BATCH_SIZE : $batch;
	}

	/**
	 * セキュリティ証跡を選ぶ条件式とその値を返す
	 *
	 * @return array{0:string,1:array<int,string>} [ SQL 断片, prepare に渡す値 ]
	 */
	private function security_action_condition(): array {
		global $wpdb;

		$parts  = [];
		$params = [];

		foreach ( self::SECURITY_ACTION_PREFIXES as $prefix ) {
			$parts[] = 'action LIKE %s';
			// ⚠️ `_` は LIKE のワイルドカード。esc_like() を通さないと
			//    `bot_guard_` が `bot_guardX` にも当たり、一般操作が証跡として居座る。
			$params[] = $wpdb->esc_like( $prefix ) . '%';
		}

		$placeholders = implode( ', ', array_fill( 0, count( self::SECURITY_ACTIONS ), '%s' ) );
		$parts[]      = "action IN ( {$placeholders} )";
		$params       = array_merge( $params, self::SECURITY_ACTIONS );

		return [ '( ' . implode( ' OR ', $parts ) . ' )', $params ];
	}

	/**
	 * クライアントIPアドレスを取得
	 *
	 * @return string|null
	 */
	private function get_client_ip(): ?string {
		$ip = RateLimiter::instance()->get_client_ip();
		return '0.0.0.0' === $ip ? null : $ip;
	}

	/**
	 * User-Agentを取得
	 *
	 * @return string|null
	 */
	private function get_user_agent(): ?string {
		if ( ! isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return null;
		}
		$ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Sanitized on this line.
		if ( '' === $ua ) {
			return null;
		}
		return substr( $ua, 0, 500 );
	}
}
