<?php
/**
 * Lockdown_State — transient-backed state machine for card-testing defense.
 *
 * Tracks two kinds of state:
 *   - Targeted fingerprints: ONE non-autoloaded option per fingerprint, named
 *     with a fixed prefix, with the expiry timestamp stored in its value.
 *     Concurrent calls for different fingerprints never race because each writes
 *     its own row. (Previously a single shared map, which had a read-then-write
 *     race that could silently drop one attacker's fingerprint during
 *     simultaneous targeting from another.) Stored as options — NOT transients —
 *     because the aggregate reads (has_any_target(), currently_targeted())
 *     enumerate these rows with a direct wp_options query, which is impossible
 *     for transients once a persistent object cache is active: with
 *     Redis/Memcached, set_transient() never touches wp_options, so the
 *     aggregate reported IDLE (and the dashboard showed nothing) even while the
 *     block path was actively stopping the attack. Expiry is enforced on read;
 *     stale rows are swept by the retention cron via prune_expired_targets().
 *   - Panic freeze: single global transient, all-or-nothing. Kept as a transient
 *     because it is read by name (get_transient), never enumerated, so the
 *     object-cache limitation above does not apply to it.
 *
 * Derived state (via current_state()):
 *   - PANIC if panic transient is live.
 *   - TARGETED if at least one non-expired targeted fingerprint exists.
 *   - IDLE otherwise.
 *
 * @package TrustLens
 * @since   1.2.2
 */

defined( 'ABSPATH' ) || exit;

class TrustLens_Lockdown_State {

	private const TARGET_PREFIX     = 'wstl_ct_tgt_';
	private const TRANSIENT_PANIC   = 'trustlens_card_testing_panic';

	public function current_state(): string {
		if ( $this->is_panic_active() ) {
			return 'PANIC';
		}
		if ( $this->has_any_target() ) {
			return 'TARGETED';
		}
		return 'IDLE';
	}

	public function is_fingerprint_blocked( string $fingerprint_hash ): bool {
		if ( $this->is_panic_active() ) {
			return true;
		}
		if ( '' === $fingerprint_hash ) {
			return false;
		}
		$expires = (int) get_option( self::TARGET_PREFIX . $fingerprint_hash, 0 );
		return $expires > time();
	}

	public function target_fingerprint( string $fingerprint_hash, int $ttl_seconds ): void {
		if ( '' === $fingerprint_hash || $ttl_seconds <= 0 ) {
			return;
		}
		// One non-autoloaded option per fingerprint — atomic per key, so two concurrent
		// attacks from different devices both persist without overwriting each other. An
		// option (not a transient) guarantees a real wp_options row the aggregate reads
		// can enumerate even under a persistent object cache. Expiry lives in the value
		// and is enforced on read; the retention cron sweeps stale rows.
		update_option( self::TARGET_PREFIX . $fingerprint_hash, time() + $ttl_seconds, false );
	}

	/**
	 * Clear a single targeted fingerprint. Symmetric with target_fingerprint();
	 * deletes only this fingerprint's option (no global clear), so demo/test
	 * teardown never touches real targeted fingerprints.
	 *
	 * @since 1.3.2
	 * @param string $fingerprint_hash Fingerprint to un-target.
	 * @return void
	 */
	public function untarget_fingerprint( string $fingerprint_hash ): void {
		if ( '' === $fingerprint_hash ) {
			return;
		}
		delete_option( self::TARGET_PREFIX . $fingerprint_hash );
	}

	/**
	 * Return all currently targeted fingerprints as [hash => expires_at].
	 *
	 * Iterates wp_options once for the admin display and the velocity recorder.
	 * Per-fingerprint reads in is_fingerprint_blocked() do NOT use this — they
	 * hit a single option by name.
	 *
	 * @return array<string,int>
	 */
	public function currently_targeted(): array {
		global $wpdb;
		$prefix = self::TARGET_PREFIX;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerates per-fingerprint option rows by name prefix; the options API has no key-pattern listing.
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT option_name, option_value
			 FROM {$wpdb->options}
			 WHERE option_name LIKE %s",
			$wpdb->esc_like( $prefix ) . '%'
		) );
		// phpcs:enable

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

		$now     = time();
		$targets = array();
		foreach ( $rows as $row ) {
			$expires = (int) $row->option_value;
			if ( $expires <= $now ) {
				continue;
			}
			$hash = substr( (string) $row->option_name, strlen( $prefix ) );
			if ( '' !== $hash ) {
				$targets[ $hash ] = $expires;
			}
		}
		return $targets;
	}

	/**
	 * Remove every targeted fingerprint. Used by uninstall and by integration tests.
	 *
	 * @since 1.2.3
	 */
	public function clear_all_targets(): void {
		global $wpdb;
		$prefix = self::TARGET_PREFIX;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate target option names by prefix, then delete via the options API.
		$names = $wpdb->get_col( $wpdb->prepare(
			"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
			$wpdb->esc_like( $prefix ) . '%'
		) );
		// phpcs:enable
		// delete_option() (not a raw DELETE) so a persistent object cache is invalidated
		// too — a direct DELETE would leave stale target values cached under Redis/Memcached.
		foreach ( (array) $names as $name ) {
			delete_option( (string) $name );
		}
	}

	/**
	 * Delete target option rows whose embedded expiry has already passed.
	 *
	 * Options do not self-expire the way transients do, so this housekeeping stops
	 * the wstl_ct_tgt_* rows from accumulating after an attack subsides. Correctness
	 * never depends on it — every read already treats an elapsed expiry as
	 * "not targeted" — so it runs lazily from the daily retention cron.
	 *
	 * @since 1.3.8
	 * @return int Number of expired rows removed.
	 */
	public function prune_expired_targets(): int {
		global $wpdb;
		$prefix = self::TARGET_PREFIX;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate expired target option names, then delete via the options API.
		$names = $wpdb->get_col( $wpdb->prepare(
			"SELECT option_name FROM {$wpdb->options}
			 WHERE option_name LIKE %s
			   AND CAST(option_value AS UNSIGNED) <= %d",
			$wpdb->esc_like( $prefix ) . '%',
			time()
		) );
		// phpcs:enable
		foreach ( (array) $names as $name ) {
			delete_option( (string) $name );
		}
		return count( (array) $names );
	}

	public function activate_panic_freeze( int $ttl_seconds ): void {
		set_transient( self::TRANSIENT_PANIC, time() + $ttl_seconds, $ttl_seconds );
	}

	public function deactivate_panic_freeze(): void {
		delete_transient( self::TRANSIENT_PANIC );
	}

	public function is_panic_active(): bool {
		$expires = (int) get_transient( self::TRANSIENT_PANIC );
		return $expires > time();
	}

	public function panic_expires_at(): ?int {
		$expires = (int) get_transient( self::TRANSIENT_PANIC );
		return $expires > time() ? $expires : null;
	}

	/**
	 * Fast "any active target?" check used by current_state() on every request.
	 *
	 * Uses LIMIT 1 so the query short-circuits and the option-name LIKE works
	 * efficiently against wp_options' index. Reads real option rows (not
	 * transients), so it returns correct results whether or not a persistent
	 * object cache is active.
	 *
	 * @return bool
	 */
	private function has_any_target(): bool {
		global $wpdb;
		$prefix = self::TARGET_PREFIX;
		$now    = time();

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- See currently_targeted() rationale.
		$row = $wpdb->get_var( $wpdb->prepare(
			"SELECT option_value
			 FROM {$wpdb->options}
			 WHERE option_name LIKE %s
			   AND CAST(option_value AS UNSIGNED) > %d
			 LIMIT 1",
			$wpdb->esc_like( $prefix ) . '%',
			$now
		) );
		// phpcs:enable

		return null !== $row;
	}
}
