<?php
/**
 * CacheLoop – Lightweight Event Stats
 *
 * Tracks daily cache hit/miss/purge counts as one-line-per-event log files
 * (wp-content/cache/cacheloop/stats/YYYY-MM-DD-{hits,misses,purges}.log),
 * written with a single FILE_APPEND call and no locking. Aggregation
 * (counting lines for a date range) only happens when a report is actually
 * generated — never on the request path. Feeds the `healthloop_data_providers`
 * bridge (see class-cl-healthloop-bridge.php).
 *
 * Why append-only instead of a read-modify-write counter: benchmarked in
 * Docker (php:8.3-cli on a named volume, 2026-08-22) — a JSON counter file
 * protected by flock() took ~7x longer under contention than a lock-free
 * append (0.749s vs 0.106s for 10k writes at 50 concurrent processes; 30k
 * writes at 100 processes with the lock-free approach: 0.194s, zero lost or
 * malformed lines). Every writer serializing on a lock is exactly the
 * overhead this plugin exists to avoid on its own request path. A short
 * line appended with FILE_APPEND relies on POSIX's O_APPEND guarantee — the
 * kernel performs the seek-to-end and write as one atomic operation for
 * writes under PIPE_BUF, so concurrent appends interleave safely with no
 * lock needed.
 *
 * Known limitation, not yet verified: this atomicity guarantee is POSIX
 * local-filesystem semantics. It has not been tested on network filesystems
 * (e.g. NFS, used by some enterprise managed hosts), where O_APPEND is not
 * always atomic across clients. Practical risk is low given how small each
 * write is, but treat this as a documented gap, not a cleared one — see
 * CLAUDE.md.
 *
 * @package CacheLoop
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class CACHELOOP_Stats
 */
class CACHELOOP_Stats {

	/**
	 * Registers purge tracking. Hits/misses aren't fired as actions, so
	 * those are recorded directly from the code that observes them (the
	 * page-cache fallback path and the advanced-cache.php drop-in).
	 */
	public function __construct() {
		add_action( 'cacheloop_url_purged', array( __CLASS__, 'record_purge' ) );
		add_action( 'cacheloop_cache_flushed', array( __CLASS__, 'record_purge' ) );
	}

	/**
	 * Records a cache hit served the "slow" way, through WordPress's
	 * template_redirect (see CACHELOOP_Page_Cache::maybe_serve_or_start_cache()'s
	 * fallback branch). Most real hits are served earlier, pre-bootstrap, by
	 * drop-ins/advanced-cache.php, which duplicates this same append — see
	 * that file's comment and this class's append() docblock.
	 *
	 * @return void
	 */
	public static function record_hit() {
		self::append( 'hits' );
	}

	/**
	 * Records a cache miss that required regenerating the page (no cache
	 * file found). Deliberately not called for every bypass reason
	 * (logged-in user, query string, WooCommerce pages, etc.) — those
	 * aren't cache misses in the sense a HealthLoop report should surface,
	 * they're pages that were never eligible for caching in the first
	 * place. Counting them would inflate the miss count with an admin's own
	 * traffic and misrepresent actual cache performance to a report reader.
	 *
	 * @return void
	 */
	public static function record_miss() {
		self::append( 'misses' );
	}

	/**
	 * Records a cache purge event. Hooked to both `cacheloop_url_purged`
	 * (one per purged URL) and `cacheloop_cache_flushed` (one per full
	 * flush) — see CACHELOOP_Page_Cache::purge_post_cache() and
	 * ::flush_all(). Ignores whatever argument the action passes.
	 *
	 * @return void
	 */
	public static function record_purge() {
		self::append( 'purges' );
	}

	/**
	 * Aggregates recorded events for a date range by summing the relevant
	 * daily log files' line counts. Read-time only — never called from the
	 * request path.
	 *
	 * Both endpoints are converted to UTC before bucketing, matching the
	 * gmdate() used to name the log files below, regardless of what
	 * timezone the caller's DateTime objects carry.
	 *
	 * @param  DateTime $since Period start.
	 * @param  DateTime $until Period end.
	 * @return array{hits:int,misses:int,purges:int}
	 */
	public static function get_summary( DateTime $since, DateTime $until ) {
		$utc = new DateTimeZone( 'UTC' );

		$cursor = new DateTime( ( clone $since )->setTimezone( $utc )->format( 'Y-m-d' ), $utc );
		$end    = new DateTime( ( clone $until )->setTimezone( $utc )->format( 'Y-m-d' ), $utc );

		$totals = array(
			'hits'   => 0,
			'misses' => 0,
			'purges' => 0,
		);

		$dir = self::stats_dir();

		while ( $cursor <= $end ) {
			$day = $cursor->format( 'Y-m-d' );

			foreach ( array_keys( $totals ) as $type ) {
				$file = $dir . $day . '-' . $type . '.log';
				if ( file_exists( $file ) ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents -- small internal log file, not user-facing.
					$totals[ $type ] += substr_count( (string) file_get_contents( $file ), "\n" );
				}
			}

			$cursor->modify( '+1 day' );
		}

		return $totals;
	}

	/**
	 * Appends a single-character line to today's (UTC) log file for the
	 * given event type. No read, no lock — see the class docblock for why.
	 *
	 * IMPORTANT: drop-ins/advanced-cache.php duplicates this exact append
	 * (same file layout, same lock-free write) because it runs pre-bootstrap,
	 * before this class file is reachable — it has no plugin autoloader or
	 * CACHELOOP_PLUGIN_DIR yet. If you change the log file naming, the
	 * write, or the stats directory here, update that copy too or the two
	 * will silently drift apart.
	 *
	 * @param  string $type 'hits' | 'misses' | 'purges'.
	 * @return void
	 */
	private static function append( $type ) {
		$dir = self::stats_dir();

		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
		}

		$file = $dir . gmdate( 'Y-m-d' ) . '-' . $type . '.log';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite -- deliberate lock-free append into wp-content/cache/; see class docblock for the concurrency reasoning.
		@file_put_contents( $file, "1\n", FILE_APPEND );
	}

	/**
	 * Absolute path to the stats directory, trailing slash included.
	 * Inherits access protection from the .htaccess CacheLoop already
	 * writes at CACHELOOP_CACHE_DIR (see cacheloop_protect_cache_dir()) —
	 * subdirectories don't need their own copy.
	 *
	 * @return string
	 */
	private static function stats_dir() {
		return CACHELOOP_CACHE_DIR . 'stats/';
	}
}
