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

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

use ConfManager\Traits\Singleton;

/**
 * 監査ログの間引き・匿名化 日次 cron
 *
 * 🔴 **これが入るまで、監査ログを間引く経路は製品に 1 つも無かった。**
 * `AuditLog::purge()` は定義とテストからしか呼ばれておらず、
 * `wp_conf_audit_log` を減らす経路は `uninstall.php` 以外に存在しなかった。
 * 書き込みは 45 箇所あるので、**入れっぱなしで無制限に増え続けていた**。
 *
 * ⚠️ **これは「増えるから消す」だけの機能ではない。**
 * 監査ログは `ip_address` と `user_agent` を持つ。間引きが無いということは
 * **個人データを無期限に持ち続ける**ということでもある。
 *
 * 保持期間は 2 段（{@see AuditLog::purge()}）。一律で切ると不正検知や
 * レート制限の証跡まで同時に消えるため、セキュリティ証跡だけ長く残す。
 * さらに、**一般操作の IP と User-Agent は保持期間より早く落とす**
 * （{@see AuditLog::anonymize_old_rows()}）。
 *
 * @since 1.6.2
 */
class AuditLogCron {

	use Singleton;

	private const HOOK_DAILY = 'conf_manager_audit_log_purge_daily';

	/**
	 * この機能が初めて動いた時刻を入れておく option
	 *
	 * 🔴 **一度入れたら二度と上書きしない。** 上書きすると猶予が永久に終わらない。
	 */
	private const GRACE_OPTION = 'conf_manager_audit_log_first_seen_at';

	/**
	 * 初回の猶予（日数）
	 *
	 * 🔴 **WP.org 配布では「更新前に申し出てください」が届かない。**
	 * 自動更新が有効なサイトでは、利用者が何も読まないうちに更新が当たり、
	 * readme の Upgrade Notice は「更新画面で見られるかもしれない」程度。
	 * **反応があったときにはもう消えている。**
	 *
	 * そこで、この機能が初めて動いてから一定期間は**何も消さない・落とさない**。
	 * その間、管理画面に**閉じられる**知らせを出して、保持期間を延ばす余地を残す。
	 * 指摘は Pro（`handoff:notes/2026-08-27-pro-to-free-3.md` の A）。
	 */
	public const GRACE_DAYS = 14;

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

	/**
	 * 日次フックの名前
	 *
	 * @return string
	 */
	public static function hook(): string {
		return self::HOOK_DAILY;
	}

	/**
	 * フックを登録する（メインクラスから呼ばれる）
	 */
	public function register_hooks(): void {
		add_action( self::HOOK_DAILY, [ $this, 'run_daily' ] );
		$this->remember_first_seen();
		if ( ! wp_next_scheduled( self::HOOK_DAILY ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::HOOK_DAILY );
		}
	}

	/**
	 * この機能が初めて動いた時刻を 1 度だけ記録する
	 *
	 * 🔴 **既にあれば絶対に上書きしない。** 上書きすると、毎回の起動で猶予が
	 * 延びて**永久に間引きが始まらない**（＝個人データを無期限に持ち続ける）。
	 */
	private function remember_first_seen(): void {
		if ( '' === (string) get_option( self::GRACE_OPTION, '' ) ) {
			update_option( self::GRACE_OPTION, (string) time(), false );
		}
	}

	/**
	 * 初回に記録した時刻（0 = まだ記録されていない）
	 *
	 * @return int
	 */
	public static function first_seen_at(): int {
		return (int) get_option( self::GRACE_OPTION, 0 );
	}

	/**
	 * 猶予が明ける時刻
	 *
	 * 🔴 **記録が無いときは「いま始まった」ことにする。**
	 * 0 や遥か過去に倒すと、**記録が無いサイトで猶予ゼロ＝いきなり削除が始まる**。
	 * 実際に踏むのは細い経路（option が消えた・読めなかった）だが、
	 * **踏んだときの結果が「即座に削除」なので、細さで許容していい向きではない。**
	 * **迷ったときに倒す先は必ず「まだ始めない」側。**
	 * 指摘は Pro（`handoff:notes/2026-08-27-pro-to-free-4.md` の ③）。
	 *
	 * @return int
	 */
	public static function grace_ends_at(): int {
		$started = self::first_seen_at();
		if ( $started < 1 ) {
			$started = time();
		}

		$days = (int) apply_filters( 'conf_manager_audit_log_grace_days', self::GRACE_DAYS );
		if ( $days < 0 ) {
			$days = self::GRACE_DAYS;
		}

		return $started + ( $days * DAY_IN_SECONDS );
	}

	/**
	 * まだ猶予の中か
	 *
	 * @return bool
	 */
	public static function is_in_grace(): bool {
		return time() < self::grace_ends_at();
	}

	/**
	 * 日次処理本体（テストからは do_action で直接起動できる）
	 *
	 * 先に消してから匿名化する。順序を逆にすると、その日のうちに消える行まで
	 * 一度 UPDATE することになる。
	 *
	 * ⚠️ **猶予の中では何もしない。** 消えることに気づく機会を利用者に渡すため。
	 *
	 * @return array{deleted:int, anonymized:int, in_grace:bool} 消した件数と、IP/UA を落とした件数
	 */
	public function run_daily(): array {
		if ( self::is_in_grace() ) {
			return [
				'deleted'    => 0,
				'anonymized' => 0,
				'in_grace'   => true,
			];
		}

		$days           = (int) apply_filters( 'conf_manager_audit_log_retention_days', AuditLog::RETENTION_DAYS_DEFAULT );
		$security_days  = (int) apply_filters( 'conf_manager_audit_log_security_retention_days', AuditLog::RETENTION_DAYS_SECURITY );
		$anonymize_days = (int) apply_filters( 'conf_manager_audit_log_anonymize_days', AuditLog::ANONYMIZE_DAYS_DEFAULT );

		// 🔴 0 以下を渡すと「今より未来」を切り捨て条件にしてしまい**全件消える**。
		//    フィルタは第三者が書き換えられるので、ここで下限を守る。
		if ( $days < 1 ) {
			$days = AuditLog::RETENTION_DAYS_DEFAULT;
		}
		if ( $security_days < 1 ) {
			$security_days = AuditLog::RETENTION_DAYS_SECURITY;
		}
		if ( $anonymize_days < 1 ) {
			$anonymize_days = AuditLog::ANONYMIZE_DAYS_DEFAULT;
		}

		$audit = AuditLog::instance();

		return [
			'deleted'    => $audit->purge( $days, $security_days ),
			'anonymized' => $audit->anonymize_old_rows( $anonymize_days ),
			'in_grace'   => false,
		];
	}
}
