<?php
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
 * WC_Appointments_Cache class.
 *
 * @package WooCommerce-Appointments/Classes
 */

/**
 * Helper cache class.
 *
 * @since 4.7.0
 */
class WC_Appointments_Cache {
	/**
	 * Constructor.
	 *
	 * @since 4.7.0
	 */
	public function __construct() {
		add_action( 'woocommerce_appointment_cancelled', [ self::class, 'clear_cache' ] );
		add_action( 'woocommerce_appointment_cancelled', [ self::class, 'clear_cron_hooks' ] );
		add_action( 'before_delete_post', [ self::class, 'clear_cache' ] );
		add_action( 'wp_trash_post', [ self::class, 'clear_cache' ] );
		add_action( 'untrash_post', [ self::class, 'clear_cache' ] );
		add_action( 'save_post', [ self::class, 'clear_cache_on_save_post' ] );
		add_action( 'woocommerce_order_status_changed', [ self::class, 'clear_cache' ] );
		add_action( 'woocommerce_pre_payment_complete', [ self::class, 'clear_cache' ] );

		// Scheduled events.
		add_action( 'delete_appointment_transients', [ self::class, 'clear_cache' ] );
		add_action( 'delete_appointment_ts_transients', [ self::class, 'clear_cache' ] );
		add_action( 'delete_appointment_dr_transients', [ self::class, 'clear_cache' ] );
		add_action( 'delete_appointment_staff_transients', [ self::class, 'clear_cache' ] );
		add_action( 'delete_appointment_staff_ids_transients', [ self::class, 'clear_cache' ] );
	}

	/**
     * Determines if debug mode is enabled. Used to
     * get around stale cache when testing.
     *
     * @since 4.7.0
     */
    public static function is_debug_mode(): bool {
		return true === WC_APPOINTMENTS_DEBUG;
	}

	/**
	 * Invalidate cache group.
	 *
	 * @param string $group Group of cache to clear.
	 * @since 4.8.10
	 */
 	public static function invalidate_cache_group( string $group ): void {
 		wp_cache_set( 'wc_' . $group . '_cache_prefix', microtime(), $group );
 	}

	/**
	 * Gets the cache transient from db.
	 *
	 * @since 4.7.0
	 * @param string $name Name of the cache.
	 * @return mixed The cached data or false if not found.
	 */
	public static function get( $name = '' ) {
		if ( empty( $name ) || self::is_debug_mode() ) {
			return false;
		}

		return get_transient( $name );
	}

	/**
	 * Sets the cache transient to db.
	 *
	 * @since 4.7.0
	 * @param string $name       Name of the cache.
	 * @param mixed  $data       The data to be cached.
	 * @param int    $expiration When to expire the cache (in seconds).
	 */
	public static function set( $name = '', $data = null, $expiration = YEAR_IN_SECONDS ): void {
		// Avoid unnecessary database writes by checking if value already exists and matches.
		// This prevents slow INSERT ... ON DUPLICATE KEY UPDATE queries when the value hasn't changed.
		// Only skip for database-only caching (not object cache) to avoid unnecessary DB queries.
		if ( ! wp_using_ext_object_cache() ) {
			$existing = get_transient( $name );
			// Use strict comparison to ensure exact match, including type.
			if ( false !== $existing && $existing === $data ) {
				// Value already exists and matches exactly, skip the write to avoid slow query.
				// Note: We don't check expiration here as get_transient() already handles that.
				return;
			}
		}
		
		set_transient( $name, $data, $expiration );

		// Track transients to avoid LIKE queries on delete.
		$groups = [
			'schedule_fo_'        => 'schedule_fo',
			'schedule_ts_'        => 'schedule_ts',
			'schedule_dr_'        => 'schedule_dr',
			'staff_ps_'           => 'staff_ps',
			'schedule_staff_ids_' => 'schedule_staff_ids',
		];

		foreach ( $groups as $prefix => $group ) {
			if ( 0 === strpos( $name, $prefix ) ) {
				$keys   = get_option( 'wca_transient_keys_' . $group, [] );
				$keys[] = $name;
				update_option( 'wca_transient_keys_' . $group, array_unique( $keys ), false );
				break;
			}
		}
	}

	/**
	 * Deletes the cache transient from db.
	 *
	 * @since 4.7.0
	 * @param string $name Name of the cache.
	 */
	public static function delete( $name = '' ): void {
		delete_transient( $name );
	}

	public static function clear_cache(): void {
		WC_Cache_Helper::get_transient_version( 'appointments', true );

		// It only makes sense to delete transients from the DB if we're not using an external cache.
		if ( ! wp_using_ext_object_cache() ) {
			self::delete_appointment_transients();
			self::delete_appointment_ts_transients();
			self::delete_appointment_dr_transients();
			self::delete_appointment_staff_transients();
			self::delete_appointment_staff_ids_transients();
		} else {
			// Flush Memcache or Memcached.
			wp_cache_flush();
		}
	}

	/**
	 * Clear cron hooks for appointment
	 *
	 * @param mixed $post_id
	 */
	public static function clear_cron_hooks( $post_id = 0 ): void {
		// Use all-actions unschedule to guarantee no duplicates remain.
		// This ensures any previously queued duplicate actions are fully cleared.
		as_unschedule_all_actions( 'wc-appointment-reminder', [ $post_id ], 'wca' );
		as_unschedule_all_actions( 'wc-appointment-complete', [ $post_id ], 'wca' );
		as_unschedule_all_actions( 'wc-appointment-remove-inactive-cart', [ $post_id ], 'wca' );
		as_unschedule_all_actions( 'wc-appointment-follow-up', [ $post_id ], 'wca' );

		// Trigger action hook when the cron is cleared.
		do_action( 'woocommerce_appointment_clear_cron_hooks', $post_id );
	}

	/**
	 * Clears the transients when appointment is edited.
	 *
	 * @param int $post_id Post ID.
	 * @return int|void Post ID.
	 */
	public static function clear_cache_on_save_post( $post_id ) {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return $post_id;
		}

		if ( 'wc_appointment' !== get_post_type( $post_id ) && 'product' !== get_post_type( $post_id ) ) {
			return $post_id;
		}

		self::clear_cache();
	}

	/**
	 * Delete Appointment Related Transients
	 */
	 public static function delete_appointment_transients(): void {
 		$transients = get_option( 'wca_transient_keys_schedule_fo', [] );
 		foreach ( $transients as $transient ) {
 			self::delete( $transient );
 		}
 		delete_option( 'wca_transient_keys_schedule_fo' );
 	}

	/**
	 * Delete Appointment Time Slots Related Transients
	 */
	public static function delete_appointment_ts_transients(): void {
		$transients = get_option( 'wca_transient_keys_schedule_ts', [] );
		foreach ( $transients as $transient ) {
			self::delete( $transient );
		}
		delete_option( 'wca_transient_keys_schedule_ts' );
	}

	/**
	 * Delete Appointment Date Range Related Transients
	 */
	public static function delete_appointment_dr_transients(): void {
		$transients = get_option( 'wca_transient_keys_schedule_dr', [] );
		foreach ( $transients as $transient ) {
			self::delete( $transient );
		}
		delete_option( 'wca_transient_keys_schedule_dr' );
	}

	/**
	 * Flush transients for all products related to a specific staff.
	 *
	 * @param  string|int $user_id Staff User ID.
	 * @since  4.10.7
	 */
	public static function flush_staff_products_transients( string $user_id ): void {
		$staff = new WC_Product_Appointment_Staff( $user_id );

		if ( ! $staff ) {
			return;
		}

		// Get product IDs for staff.
		$product_ids = $staff->get_product_ids();

		if ( ! $product_ids ) {
			return;
		}

		foreach ( $product_ids as $product_id ) {
			self::delete_appointment_slots_transient( $product_id );
		}

		// Delete cache.
		wp_cache_delete( 'read_staff_object_' .  $user_id, 'read_staff_object' );
	}

	/**
	 * Clear appointment slots transient.
	 * If there are staff find connected products and clear their transients.
	 *
	 * @param  WC_Appointment $appointment
	 * @since  4.10.7
	 */
	public static function flush_all_appointment_connected_transients( $appointment ): void {
		if ( ! $appointment ) {
			return;
		}

		$staff_ids = $appointment->get_staff_ids();
		if ( $staff_ids ) {
			// Array.
			if ( is_array( $staff_ids ) ) {
				foreach ( $staff_ids as $staff_id ) {
					// We have staff. Other products may be affected.
					self::flush_staff_products_transients( $staff_id );
				}
			// Int.
			} else {
				// We have staff. Other products may be affected.
				self::flush_staff_products_transients( (int) $staff_ids );
			}
			return; #stop here since products are already flushed.
		}

		// No resource. Just flush for this appointment product.
		$product_id = $appointment->get_product_id();
		self::delete_appointment_slots_transient( $product_id );
	}

	/**
	 * Delete Staff Related Transients
	 */
	public static function delete_appointment_staff_ids_transients(): void {
		$transients = get_option( 'wca_transient_keys_schedule_staff_ids', [] );
		foreach ( $transients as $transient ) {
			self::delete( $transient );
		}
		delete_option( 'wca_transient_keys_schedule_staff_ids' );
	}

	/**
	 * Delete Staff Related Transients
	 */
	public static function delete_appointment_staff_transients(): void {
		$transients = get_option( 'wca_transient_keys_staff_ps', [] );
		foreach ( $transients as $transient ) {
			self::delete( $transient );
		}
		delete_option( 'wca_transient_keys_staff_ps' );
	}

	/**
	 * Delete appointment slots transient.
	 *
	 * In contexts where we have a product id, it will only delete the specific ones.
	 * However, not all contexts will have a product id, e.g. Global Availability.
	 *
	 * @param  int|null $appointable_product_id
	 * @since  4.5.0
	 */
	public static function delete_appointment_slots_transient( $appointable_product_id = null ): void {
		$appointment_slots_transient_keys = array_filter( (array) self::get( 'appointment_slots_transient_keys' ) );

		if ( is_int( $appointable_product_id ) ) {
			if ( ! isset( $appointment_slots_transient_keys[ $appointable_product_id ] ) ) {
				return;
			}

			// Get a list of flushed transients
			$flushed_transients = array_map(
			    function( $transient_name ) {
					self::delete( $transient_name );
					return $transient_name;
				},
			    $appointment_slots_transient_keys[ $appointable_product_id ],
			);

			// Remove the flushed transients referenced from other product ids (if there's such a cross-reference)
			array_walk(
			    $appointment_slots_transient_keys,
			    function( &$transients, $appointable_product_id ) use ( $flushed_transients ): void {
					$transients = array_values( array_diff( $transients, $flushed_transients ) );
				},
			);

			$appointment_slots_transient_keys = array_filter( $appointment_slots_transient_keys );

			unset( $appointment_slots_transient_keys[ $appointable_product_id ] );
			self::set( 'appointment_slots_transient_keys', $appointment_slots_transient_keys, YEAR_IN_SECONDS );
		} else {
			$transients = array_unique(
			    array_reduce(
			        $appointment_slots_transient_keys,
			        fn(array $result, $item): array => array_merge( $result, $item ),
			        [],
			    ),
			);

			foreach ( $transients as $transient_key ) {
				self::delete( $transient_key );
			}

			self::delete( 'appointment_slots_transient_keys' );
		}
	}
}

new WC_Appointments_Cache();
