<?php
/**
 * Class Database_Cache
 *
 * @package WooCommerce\Payments
 */

namespace WCPay;

use WCPay\MultiCurrency\Interfaces\MultiCurrencyCacheInterface;
use WCPay\MultiCurrency\Utils;

defined( 'ABSPATH' ) || exit; // block direct access.

/**
 * A class for caching data as an option in the database.
 */
class Database_Cache implements MultiCurrencyCacheInterface {
	const ACCOUNT_KEY                  = 'wcpay_account_data';
	const ONBOARDING_FIELDS_DATA_KEY   = 'wcpay_onboarding_fields_data';
	const BUSINESS_TYPES_KEY           = 'wcpay_business_types_data';
	const PAYMENT_PROCESS_FACTORS_KEY  = 'wcpay_payment_process_factors';
	const FRAUD_SERVICES_KEY           = 'wcpay_fraud_services_data';
	const RECOMMENDED_PAYMENT_METHODS  = 'wcpay_recommended_payment_methods';
	const ADDRESS_AUTOCOMPLETE_JWT_KEY = 'wcpay_address_autocomplete_jwt';

	/**
	 * Refresh during AJAX calls is avoided, but white-listing
	 * a key here will allow the refresh to happen.
	 *
	 * @var string[]
	 */
	const AJAX_ALLOWED_KEYS = [
		self::PAYMENT_PROCESS_FACTORS_KEY,
	];

	/**
	 * Payment methods cache key prefix. Used in conjunction with the customer_id to cache a customer's payment methods.
	 */
	const PAYMENT_METHODS_KEY_PREFIX = 'wcpay_pm_';

	/**
	 * Dispute status counts cache key.
	 *
	 * @var string
	 */
	const DISPUTE_STATUS_COUNTS_KEY = 'wcpay_dispute_status_counts_cache';

	/**
	 * Dispute status counts cache key for test mode.
	 *
	 * @var string
	 */
	const DISPUTE_STATUS_COUNTS_KEY_TEST_MODE = 'wcpay_test_dispute_status_counts_cache';

	/**
	 * Active disputes cache key.
	 *
	 * @var string
	 */
	const ACTIVE_DISPUTES_KEY = 'wcpay_active_dispute_cache';

	/**
	 * Cache key for authorization summary data like count, total amount, etc.
	 *
	 * @var string
	 */
	const AUTHORIZATION_SUMMARY_KEY = 'wcpay_authorization_summary_cache';

	/**
	 * Cache key for authorization summary data like count, total amount, etc in test mode.
	 *
	 * @var string
	 */
	const AUTHORIZATION_SUMMARY_KEY_TEST_MODE = 'wcpay_test_authorization_summary_cache';

	/**
	 * Cache key for eligible connect incentive data.
	 */
	const CONNECT_INCENTIVE_KEY = 'wcpay_connect_incentive';

	/**
	 * Tracking info cache key.
	 *
	 * @var string
	 */
	const TRACKING_INFO_KEY = 'wcpay_tracking_info_cache';

	/**
	 * Refresh disabled flag, controlling the behaviour of the get_or_add function.
	 *
	 * @var bool
	 */
	private $refresh_disabled;

	/**
	 * In-memory cache for the duration of a single request.
	 *
	 * This is used to avoid multiple database reads for the same data and as a backstop in case the database write fails,
	 * thus ensuring the cache generator is not called multiple times (which would mean multiple API calls to our platform).
	 *
	 * @var array
	 */
	private $in_memory_cache = [];

	/**
	 * Class constructor.
	 */
	public function __construct() {
		$this->refresh_disabled = false;
	}

	/**
	 * Initializes this class's WP hooks.
	 *
	 * @return void
	 */
	public function init_hooks() {
		add_action( 'action_scheduler_before_execute', [ $this, 'disable_refresh' ] );
	}

	/**
	 * Gets a value from cache or regenerates and adds it to the cache.
	 *
	 * @param string   $key           The options key to cache the data under.
	 * @param callable $generator     Function/callable regenerating the missing value. If null or false is returned, it will be treated as an error.
	 * @param callable $validate_data Function/callable validating the data after it is retrieved from the cache. If it returns false, the cache will be refreshed.
	 * @param boolean  $force_refresh Regenerates the cache regardless of its state if true.
	 * @param boolean  $refreshed     Is set to true if the cache has been refreshed without errors and with a non-empty value.
	 *
	 * @return mixed|null The cached value. NULL on failure to regenerate or validate the data.
	 */
	public function get_or_add( string $key, callable $generator, callable $validate_data, bool $force_refresh = false, bool &$refreshed = false ) {
		$cache_contents = $this->get_from_cache( $key );
		$data           = null;
		$old_data       = null;

		// If the stored data is valid, prepare it for return in case we don't need to refresh.
		// Also initialize old_data in case of errors.
		if ( is_array( $cache_contents ) && array_key_exists( 'data', $cache_contents ) && $validate_data( $cache_contents['data'] ) ) {
			$data     = $cache_contents['data'];
			$old_data = $data;
		}

		if ( $this->should_refresh_cache( $key, $cache_contents, $validate_data, $force_refresh ) ) {
			try {
				$data    = $generator();
				$errored = ( false === $data || null === $data );
			} catch ( \Throwable $e ) {
				$errored = true;
			}

			$refreshed = ! $errored;

			if ( $errored ) {
				// Still return the old data on error and refresh the cache with it.
				$data = $old_data;
			}

			$this->write_to_cache( $key, $data, $errored );
		}

		return $data;
	}

	/**
	 * Gets a value from the cache.
	 *
	 * @param string $key The key to look for.
	 * @param bool   $force If set, return from the cache without checking for expiry.
	 *
	 * @return mixed|null The cache contents. NULL if the cache is expired or missing.
	 */
	public function get( string $key, bool $force = false ) {
		$cache_contents = $this->get_from_cache( $key );
		if ( is_array( $cache_contents ) && array_key_exists( 'data', $cache_contents ) ) {
			if ( ! $force && $this->is_expired( $key, $cache_contents ) ) {
				return null;
			}

			return $cache_contents['data'];
		}

		return null;
	}

	/**
	 * Stores a value in the cache.
	 *
	 * @param string $key  The key to store the value under.
	 * @param mixed  $data The value to store.
	 *
	 * @return void
	 */
	public function add( string $key, $data ) {
		$this->write_to_cache( $key, $data, false );
	}

	/**
	 * Deletes a value from the cache.
	 *
	 * @param string $key The key to delete.
	 *
	 * @return void
	 */
	public function delete( string $key ) {
		// Remove from the in-memory cache.
		unset( $this->in_memory_cache[ $key ] );

		// Remove from the DB cache.
		if ( delete_option( $key ) ) {
			// Clear the WP object cache to ensure the new data is fetched by other processes.
			wp_cache_delete( $key, 'options' );
		}
	}

	/**
	 * Deletes all cache entries that are keyed with a certain prefix.
	 *
	 * This is useful when you use dynamic cache keys.
	 *
	 * Note: Only key prefixes with known, static prefixes are allowed, for protection purposes.
	 *
	 * @param string $key_prefix The cache key prefix.
	 *
	 * @return void
	 */
	public function delete_by_prefix( string $key_prefix ) {
		// Protection against accidentally deleting all options or options that are not related to WCPay caching.
		// Feel free to update this statement as more prefix cache keys are used.
		$allowed_base_prefixes = [
			self::PAYMENT_METHODS_KEY_PREFIX,
			self::ONBOARDING_FIELDS_DATA_KEY,
			self::RECOMMENDED_PAYMENT_METHODS,
		];
		$is_allowed            = false;
		foreach ( $allowed_base_prefixes as $allowed_base_prefix ) {
			if ( strncmp( $key_prefix, $allowed_base_prefix, strlen( $allowed_base_prefix ) ) === 0 ) {
				$is_allowed = true;
				break;
			}
		}
		if ( ! $is_allowed ) {
			return; // Maybe throw exception here...
		}

		global $wpdb;

		$options = $wpdb->get_results( $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE %s", $key_prefix . '%' ) );
		foreach ( $options as $option ) {
			$this->delete( $option->option_name );
		}
	}

	/**
	 * Delete all known cache entries.
	 */
	public function delete_all() {
		$keys = [
			self::ACCOUNT_KEY,
			self::ONBOARDING_FIELDS_DATA_KEY,
			self::BUSINESS_TYPES_KEY,
			self::PAYMENT_PROCESS_FACTORS_KEY,
			self::FRAUD_SERVICES_KEY,
			self::RECOMMENDED_PAYMENT_METHODS,
			self::DISPUTE_STATUS_COUNTS_KEY,
			self::DISPUTE_STATUS_COUNTS_KEY_TEST_MODE,
			self::ACTIVE_DISPUTES_KEY,
			self::AUTHORIZATION_SUMMARY_KEY,
			self::AUTHORIZATION_SUMMARY_KEY_TEST_MODE,
			self::CONNECT_INCENTIVE_KEY,
			self::TRACKING_INFO_KEY,
		];

		foreach ( $keys as $key ) {
			$this->delete( $key );
		}

		// Delete prefix-based keys.
		$this->delete_by_prefix( self::PAYMENT_METHODS_KEY_PREFIX );
		$this->delete_by_prefix( self::ONBOARDING_FIELDS_DATA_KEY ); // It can be prefixed with the locale.
		$this->delete_by_prefix( self::RECOMMENDED_PAYMENT_METHODS ); // It can be prefixed with the locale.
	}

	/**
	 * Hook function allowing the cache refresh to be selectively disabled in certain situations
	 * (such as while running an Action Scheduler job). While the refresh is disabled, get_or_add
	 * will only return the cached value and never regenerate it, even if it's expired.
	 *
	 * @return void
	 */
	public function disable_refresh() {
		$this->refresh_disabled = true;
	}

	/**
	 * Delete all dispute-related cache entries.
	 * This ensures both live and test mode dispute counts are refreshed.
	 *
	 * @return void
	 */
	public function delete_dispute_caches() {
		$this->delete( self::DISPUTE_STATUS_COUNTS_KEY );
		$this->delete( self::DISPUTE_STATUS_COUNTS_KEY_TEST_MODE );
		$this->delete( self::ACTIVE_DISPUTES_KEY );
	}

	/**
	 * Validates the cache contents and, given the passed params and the current application state, determines whether the cache should be refreshed.
	 * See get_or_add.
	 *
	 * @param string   $key            The cache key.
	 * @param mixed    $cache_contents The cache contents.
	 * @param callable $validate_data  Callback used to validate the cached data by the callee.
	 * @param boolean  $force_refresh  Whether a refresh should be forced.
	 *
	 * @return boolean True if the cache needs to be refreshed.
	 */
	private function should_refresh_cache( string $key, $cache_contents, callable $validate_data, bool $force_refresh ): bool {
		// Always refresh if the flag is set.
		if ( $force_refresh ) {
			return true;
		}

		// Do not refresh if doing ajax or the refresh has been disabled (running an AS job).
		if (
			defined( 'DOING_CRON' )
			|| ( wp_doing_ajax() && ! in_array( $key, self::AJAX_ALLOWED_KEYS, true ) )
			|| $this->refresh_disabled ) {
			return false;
		}

		// The value of false means that there was never something cached.
		if ( false === $cache_contents ) {
			return true;
		}

		// Non-array, empty array, or missing expected fields mean corrupted data.
		// This also handles potential legacy data, which might have those keys missing.
		if ( ! is_array( $cache_contents )
			|| empty( $cache_contents )
			|| ! array_key_exists( 'data', $cache_contents )
			|| ! isset( $cache_contents['fetched'] )
			|| ! array_key_exists( 'errored', $cache_contents )
		) {
			return true;
		}

		// If the data is not errored but invalid, we should refresh it.
		if ( ! $cache_contents['errored'] && ! $validate_data( $cache_contents['data'] ) ) {
			return true;
		}

		// Refresh the expired data.
		if ( $this->is_expired( $key, $cache_contents ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get the cache contents for a certain key.
	 *
	 * @param string $key The cache key.
	 *
	 * @return array|false The cache contents (array with `data`, `fetched`, and `errored` entries).
	 *                     False if there is no cached data.
	 */
	private function get_from_cache( string $key ) {
		// Check the in-memory cache first.
		if ( isset( $this->in_memory_cache[ $key ] ) ) {
			return $this->in_memory_cache[ $key ];
		}

		// Read from the DB cache.
		$data = get_option( $key );

		// Store the data in the in-memory cache, including the case when there is no data cached (`false`).
		$this->in_memory_cache[ $key ] = $data;

		return $data;
	}

	/**
	 * Wraps the data in the cache metadata and stores it.
	 *
	 * @param string  $key     The key to store the data under.
	 * @param mixed   $data    The data to store.
	 * @param boolean $errored Whether the refresh operation resulted in an error before this has been called.
	 *
	 * @return void
	 */
	private function write_to_cache( string $key, $data, bool $errored ) {
		// Add the  data and expiry time to the array we're caching.
		$cache_contents            = [];
		$cache_contents['data']    = $data;
		$cache_contents['fetched'] = time();
		$cache_contents['errored'] = $errored;

		// Write the in-memory cache.
		$this->in_memory_cache[ $key ] = $cache_contents;

		// Create or update the DB option cache.
		// Note: Since we are adding the current time to the option value, WP will ALWAYS write the option because
		// the cache contents value is different from the current one, even if the data is the same.
		// A `false` result ONLY means that the DB write failed.
		// Yes, there is the possibility that we attempt to write the same data multiple times within the SAME second,
		// and we will mistakenly think that the DB write failed. We are OK with this false positive,
		// since the actual data is the same.
		$result = update_option( $key, $cache_contents, 'no' );
		if ( false !== $result ) {
			// If the DB cache write succeeded, clear the WP object cache to ensure the new data is fetched by other processes.
			wp_cache_delete( $key, 'options' );
		}
	}

	/**
	 * Checks if the cache contents are expired.
	 *
	 * @param string $key            The cache key.
	 * @param array  $cache_contents The cache contents.
	 *
	 * @return boolean True if the contents are expired. False otherwise.
	 */
	private function is_expired( string $key, array $cache_contents ): bool {
		$ttl     = $this->get_ttl( $key, $cache_contents );
		$expires = $cache_contents['fetched'] + $ttl;
		$now     = time();

		return $expires < $now;
	}

	/**
	 * Given the key and the cache contents, and based on the application state, determines the cache TTL.
	 *
	 * @param string $key            The cache key.
	 * @param array  $cache_contents The cache contents.
	 *
	 * @return integer The cache TTL.
	 */
	private function get_ttl( string $key, array $cache_contents ): int {
		switch ( $key ) {
			case self::ACCOUNT_KEY:
				if ( is_admin() ) {
					// Fetches triggered from the admin panel should be more frequent.
					if ( $cache_contents['errored'] ) {
						// Attempt to refresh the data quickly if the last fetch was an error.
						$ttl = 2 * MINUTE_IN_SECONDS;
					} else {
						// If the data was fetched successfully, cache it for 2h.
						$ttl = 2 * HOUR_IN_SECONDS;
					}
				} else {
					// For performance reasons, non-admin requests should use cached data for longer (24h).
					$ttl = DAY_IN_SECONDS;
				}
				break;
			case self::CURRENCIES_KEY:
				if ( defined( 'DOING_CRON' ) || is_admin() || Utils::is_admin_api_request() ) {
					// Fetches triggered from the admin panel should be more frequent.
					if ( $cache_contents['errored'] ) {
						// Attempt to refresh the data quickly if the last fetch was an error.
						$ttl = 2 * MINUTE_IN_SECONDS;
					} else {
						// If the data was fetched successfully, cache it for 3h.
						$ttl = 3 * HOUR_IN_SECONDS;
					}
				} else {
					// For performance reasons, non-admin requests should use cached data for longer (12h).
					$ttl = 12 * HOUR_IN_SECONDS;
				}
				break;
			case self::BUSINESS_TYPES_KEY:
			case self::ONBOARDING_FIELDS_DATA_KEY:
				// Cache these for a week.
				$ttl = WEEK_IN_SECONDS;
				break;
			case self::CONNECT_INCENTIVE_KEY:
				$ttl = $cache_contents['data']['ttl'] ?? HOUR_IN_SECONDS * 6;
				break;
			case self::CONNECT_INCENTIVE_KEY . '_has_orders':
				// If the store has orders, cache for 90 days since it won't change.
				// If no orders, cache for an hour to check again soon.
				$ttl = $cache_contents['data'] ? DAY_IN_SECONDS * 90 : HOUR_IN_SECONDS;
				break;
			case self::PAYMENT_PROCESS_FACTORS_KEY:
				$ttl = 2 * HOUR_IN_SECONDS;
				break;
			case self::TRACKING_INFO_KEY:
				$ttl = $cache_contents['errored'] ? 2 * MINUTE_IN_SECONDS : MONTH_IN_SECONDS;
				break;
			case self::ADDRESS_AUTOCOMPLETE_JWT_KEY:
				$ttl = 12 * HOUR_IN_SECONDS;
				break;
			default:
				// Default to 24h.
				$ttl = DAY_IN_SECONDS;
				break;
		}

		return apply_filters( 'wcpay_database_cache_ttl', $ttl, $key, $cache_contents );
	}
}
