<?php
/**
 * Transient-based aggregation cache.
 *
 * Wraps WordPress transients with a stable key namespace and a single
 * flush hook (`trx_ad_cache_flush_aggregations`). Used by SearchController
 * for /search-options and by VehicleQuery for repeated counts/distincts
 * during a single request.
 *
 * Per ADR-28.
 *
 * @package ThemeREX\AutoDealer
 * @version 1.0.0
 */

declare(strict_types=1);

namespace ThemeREX\AutoDealer\Cache;

defined( 'ABSPATH' ) || exit;
/**
 * Transient cache wrapper with bulk-flush support.
 */
final class AggregationCache {

	/**
	 * Default TTL: 1 hour (per ADR-28).
	 */
	private const DEFAULT_TTL = HOUR_IN_SECONDS;

	/**
	 * Transient key prefix. All keys flushed together.
	 */
	private const PREFIX = 'trx_ad_agg_';

	/**
	 * Option key holding the registered cache-key index, used for bulk flush.
	 */
	private const INDEX_OPTION = 'trx_ad_agg_keys';

	/**
	 * Register hooks.
	 */
	public function register(): void {
		add_action( 'save_post_trx_ad_listing', array( $this, 'flush' ) );
		add_action( 'deleted_post', array( $this, 'flushOnDelete' ), 10, 2 );
		add_action( 'trx_ad_cache_flush_aggregations', array( $this, 'flush' ) );
		add_action( 'trx_ad_vehicle_specs_synced', array( $this, 'flush' ) );
	}

	/**
	 * Get a cached value, or compute it and cache it.
	 *
	 * @template T
	 * @param string   $name     Logical cache name (e.g. "search_options" or "count_xxhash").
	 * @param callable $producer No-arg callable that returns the value to cache.
	 * @param int|null $ttl      Optional TTL override in seconds.
	 * @return mixed The cached or freshly-produced value.
	 */
	public function remember( string $name, callable $producer, ?int $ttl = null ): mixed {
		$key    = $this->key( $name );
		$cached = get_transient( $key );

		if ( false !== $cached ) {
			return $cached;
		}

		$value = $producer();
		set_transient( $key, $value, $ttl ?? self::DEFAULT_TTL );
		$this->indexKey( $key );

		return $value;
	}

	/**
	 * Read a cached value (no producer fallback).
	 *
	 * @return mixed False if not cached.
	 */
	public function get( string $name ): mixed {
		return get_transient( $this->key( $name ) );
	}

	/**
	 * Store a value.
	 */
	public function set( string $name, mixed $value, ?int $ttl = null ): bool {
		$key = $this->key( $name );
		$this->indexKey( $key );
		return set_transient( $key, $value, $ttl ?? self::DEFAULT_TTL );
	}

	/**
	 * Forget a single key.
	 */
	public function forget( string $name ): bool {
		$key   = $this->key( $name );
		$index = $this->getIndex();
		unset( $index[ $key ] );
		update_option( self::INDEX_OPTION, $index, false );
		return delete_transient( $key );
	}

	/**
	 * Flush all aggregation transients we registered.
	 *
	 * Two-pronged delete: the index for fast O(n) walk + a SQL fallback
	 * that catches any transient with our prefix. The fallback exists
	 * because `indexKey()` writes a non-autoload option that some hosts
	 * (and the SQLite drop-in we use for tests) can race-lose, leaving the
	 * index out of sync with the actual transients. Without this fallback
	 * a stale `archive_filter_options` survives every save_post hook —
	 * the exact bug the inventory filter sidebar hit.
	 */
	public function flush(): void {
		global $wpdb;

		foreach ( array_keys( $this->getIndex() ) as $key ) {
			delete_transient( $key );
		}

		// Belt-and-suspenders: nuke any transient (and its timeout twin)
		// whose option_name starts with our prefix. Catches keys that fell
		// out of the index for any reason.
		$prefix_like  = $wpdb->esc_like( '_transient_' . self::PREFIX ) . '%';
		$timeout_like = $wpdb->esc_like( '_transient_timeout_' . self::PREFIX ) . '%';
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $prefix_like, $timeout_like ) );
		wp_cache_flush();

		update_option( self::INDEX_OPTION, array(), false );

		/**
		 * Fires after the aggregation cache is flushed. Listeners can clear
		 * downstream caches (object cache groups, page cache zones, etc.).
		 */
		do_action( 'trx_ad_cache_flushed' );
	}

	/**
	 * Flush only when a vehicle CPT post is deleted.
	 *
	 * @param int      $post_id Post ID being deleted.
	 * @param \WP_Post $post    Post object.
	 */
	public function flushOnDelete( int $post_id, \WP_Post $post ): void {
		if ( 'trx_ad_listing' === $post->post_type ) {
			$this->flush();
		}
	}

	/**
	 * Build a namespaced transient key.
	 *
	 * The `trx_ad_cache_key_suffix` filter lets i18n integrations (WPML,
	 * Polylang) append a language code so cache entries don't bleed across
	 * languages — `?make=Toyota` in English and Spanish produce different
	 * counts (different translated terms), so they must cache separately.
	 */
	private function key( string $name ): string {
		// Transient keys are limited to 172 chars; prefix + sha1 keeps us safe.
		$slug   = preg_match( '/^[a-z0-9_]{1,40}$/', $name ) ? $name : sha1( $name );
		$suffix = (string) apply_filters( 'trx_ad_cache_key_suffix', '', $name );
		if ( '' !== $suffix ) {
			$slug = sha1( $slug . '|' . $suffix );
		}
		return self::PREFIX . $slug;
	}

	/**
	 * Track a key in the index option for bulk flush.
	 */
	private function indexKey( string $key ): void {
		$index         = $this->getIndex();
		$index[ $key ] = true;
		update_option( self::INDEX_OPTION, $index, false );
	}

	/**
	 * @return array<string,bool>
	 */
	private function getIndex(): array {
		$index = get_option( self::INDEX_OPTION, array() );
		return is_array( $index ) ? $index : array();
	}
}
