<?php
namespace QuantumCache\Support;

\defined( 'ABSPATH' ) || exit;

/**
 * Per-request event counters with atomic persistence to wp_options.
 *
 * Counters are accumulated in static ints during the request (zero overhead),
 * then flushed once at shutdown via atomic SQL UPDATE (no read-modify-write race).
 *
 * Storage: wp_options rows with autoload=no, updated via direct SQL:
 *   UPDATE wp_options SET option_value = option_value + $delta WHERE option_name = ?
 *
 * Object-cache consistency: after every direct SQL write (flush/reset) the
 * corresponding 'options' cache group entry is deleted so the next get_option()
 * call reads the updated value from the database rather than a stale cache.
 *
 * Pro extension: call Metrics::deltas() at send_headers/shutdown for Prometheus;
 * call Metrics::totals() for wp quantumcache status or the metrics endpoint.
 */
final class Metrics {

	// -------------------------------------------------------------------------
	// In-memory accumulators (reset to 0 per request)
	// -------------------------------------------------------------------------

	private static int $query_hits   = 0;
	private static int $frag_hits    = 0;
	private static int $query_misses = 0;
	private static int $frag_misses  = 0;
	private static int $query_stores = 0;
	private static int $frag_stores  = 0;
	private static int $purge_calls  = 0;
	private static int $purge_tags   = 0;

	/** Whether the shutdown flush has been registered this request. */
	private static bool $flush_registered = false;

	// -------------------------------------------------------------------------
	// Option name map
	// -------------------------------------------------------------------------

	/** @return string[] */
	private static function option_names(): array {
		return array(
			'query_hits',
			'frag_hits',
			'query_misses',
			'frag_misses',
			'query_stores',
			'frag_stores',
			'purge_calls',
			'purge_tags',
		);
	}

	private static function option_key( string $metric ): string {
		return 'qc_metric_' . $metric;
	}

	// -------------------------------------------------------------------------
	// Activation — called from plugin activation hook alongside MySQLStore::activate()
	// -------------------------------------------------------------------------

	/**
	 * Ensure all metric options exist in wp_options (autoload=no).
	 * Safe to call repeatedly; add_option() is a no-op when the key already exists.
	 */
	public static function activate(): void {
		foreach ( self::option_names() as $metric ) {
			add_option( self::option_key( $metric ), '0', '', 'no' );
		}
	}

	// -------------------------------------------------------------------------
	// Hook registration
	// -------------------------------------------------------------------------

	public static function register(): void {
		// Ensure option rows exist for sites that were active before this version.
		self::activate();

		// Cache event hooks fired by QueryCache and FragmentCache.
		add_action( 'quantumcache_cache_hit',   array( __CLASS__, 'on_hit' ),   10, 1 );
		add_action( 'quantumcache_cache_miss',  array( __CLASS__, 'on_miss' ),  10, 1 );
		add_action( 'quantumcache_cache_store', array( __CLASS__, 'on_store' ), 10, 1 );

		// Purge events use the existing unified action.
		add_action( 'quantumcache_purge_tags',  array( __CLASS__, 'on_purge' ), 5, 1 );
	}

	// -------------------------------------------------------------------------
	// Event handlers
	// -------------------------------------------------------------------------

	/** @param string $surface 'query' or 'fragment' */
	public static function on_hit( string $surface ): void {
		if ( 'query' === $surface ) {
			++self::$query_hits;
		} else {
			++self::$frag_hits;
		}
		self::maybe_register_flush();
	}

	/** @param string $surface 'query' or 'fragment' */
	public static function on_miss( string $surface ): void {
		if ( 'query' === $surface ) {
			++self::$query_misses;
		} else {
			++self::$frag_misses;
		}
		self::maybe_register_flush();
	}

	/** @param string $surface 'query' or 'fragment' */
	public static function on_store( string $surface ): void {
		if ( 'query' === $surface ) {
			++self::$query_stores;
		} else {
			++self::$frag_stores;
		}
		self::maybe_register_flush();
	}

	/** @param string[] $tags */
	public static function on_purge( array $tags ): void {
		++self::$purge_calls;
		self::$purge_tags += count( $tags );
		self::maybe_register_flush();
	}

	// -------------------------------------------------------------------------
	// Shutdown flush
	// -------------------------------------------------------------------------

	private static function maybe_register_flush(): void {
		if ( self::$flush_registered ) {
			return;
		}
		self::$flush_registered = true;
		add_action( 'shutdown', array( __CLASS__, 'flush' ), 200 );
	}

	/**
	 * Atomically write accumulated deltas to wp_options at shutdown.
	 * Uses direct SQL UPDATE so multiple concurrent requests don't race.
	 */
	public static function flush(): void {
		global $wpdb;

		if ( ! isset( $wpdb ) || ! $wpdb instanceof \wpdb ) {
			return;
		}

		$deltas = array(
			'query_hits'   => self::$query_hits,
			'frag_hits'    => self::$frag_hits,
			'query_misses' => self::$query_misses,
			'frag_misses'  => self::$frag_misses,
			'query_stores' => self::$query_stores,
			'frag_stores'  => self::$frag_stores,
			'purge_calls'  => self::$purge_calls,
			'purge_tags'   => self::$purge_tags,
		);

		$options_table = $wpdb->options;

		// Validate table name: letters, numbers, underscore only.
		if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $options_table ) ) {
			return;
		}

		$options_table_safe = esc_sql( $options_table );

		foreach ( $deltas as $metric => $delta ) {
			if ( $delta <= 0 ) {
				continue;
			}

			$option_name = self::option_key( $metric );
			$delta_int   = (int) $delta;

			/**
			 * Single-statement atomic increment — safe under concurrent requests.
			 * No user input reaches this query: table name is validated, option name
			 * is internal, delta is a cast int.
			 *
			 * @phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
			 * @phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
			 * @phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			 */
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE `{$options_table_safe}` SET option_value = option_value + %d WHERE option_name = %s",
					$delta_int,
					$option_name
				)
			);
			/** @phpcs:enable */

			// Invalidate the object cache so the next get_option() reads the
			// incremented value from the database rather than a stale cached value.
			// Direct SQL UPDATE bypasses wp_cache_set(), making this necessary.
			wp_cache_delete( $option_name, 'options' );
		}
	}

	// -------------------------------------------------------------------------
	// Read: current-request deltas (for Pro Prometheus)
	// -------------------------------------------------------------------------

	/**
	 * Return in-memory deltas accumulated so far this request.
	 * Safe to call at send_headers or shutdown before flush().
	 *
	 * @return array<string,int>
	 */
	public static function deltas(): array {
		return array(
			'query_hits'   => self::$query_hits,
			'frag_hits'    => self::$frag_hits,
			'query_misses' => self::$query_misses,
			'frag_misses'  => self::$frag_misses,
			'query_stores' => self::$query_stores,
			'frag_stores'  => self::$frag_stores,
			'purge_calls'  => self::$purge_calls,
			'purge_tags'   => self::$purge_tags,
		);
	}

	// -------------------------------------------------------------------------
	// Read: persisted totals (for wp quantumcache status / Prometheus endpoint)
	// -------------------------------------------------------------------------

	/**
	 * Read all persisted metric totals from wp_options.
	 *
	 * @return array<string,int>
	 */
	public static function totals(): array {
		$out = array();
		foreach ( self::option_names() as $metric ) {
			$raw          = get_option( self::option_key( $metric ), '0' );
			$out[ $metric ] = (int) $raw;
		}
		return $out;
	}

	// -------------------------------------------------------------------------
	// Persistent reset (WP-CLI / admin)
	// -------------------------------------------------------------------------

	/**
	 * Zero all persisted metric counters and invalidate the object cache for
	 * each key so that the next totals() call reads fresh zeros from the DB.
	 *
	 * Called by the WP-CLI `wp quantumcache pro:metrics --reset --yes` command.
	 * Must be invoked from a context that has a live object-cache connection
	 * (i.e. the web container, not the isolated WP-CLI container) for the
	 * cache invalidation to reach a persistent store such as Redis.
	 */
	public static function reset(): void {
		foreach ( self::option_names() as $metric ) {
			$option_name = self::option_key( $metric );
			update_option( $option_name, '0' );
			wp_cache_delete( $option_name, 'options' );
		}
	}

	// -------------------------------------------------------------------------
	// Test support
	// -------------------------------------------------------------------------

	/**
	 * Reset all in-memory state. Call between test cases.
	 */
	public static function reset_for_tests(): void {
		self::$query_hits       = 0;
		self::$frag_hits        = 0;
		self::$query_misses     = 0;
		self::$frag_misses      = 0;
		self::$query_stores     = 0;
		self::$frag_stores      = 0;
		self::$purge_calls      = 0;
		self::$purge_tags       = 0;
		self::$flush_registered = false;
	}
}
