<?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 FRAUD_SERVICES_KEY           = 'wcpay_fraud_services_data';
	const RECOMMENDED_PAYMENT_METHODS  = 'wcpay_recommended_payment_methods';
	const ADDRESS_AUTOCOMPLETE_JWT_KEY = 'wcpay_address_autocomplete_jwt';

	/**
	 * 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';

	/**
	 * All cache keys.
	 *
	 * @var string[]
	 */
	const ALL_KEYS = [
		self::ACCOUNT_KEY,
		self::ADDRESS_AUTOCOMPLETE_JWT_KEY,
		self::ONBOARDING_FIELDS_DATA_KEY,
		self::BUSINESS_TYPES_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,
	];

	/**
	 * 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.
		delete_option( $key );

		// Always clear the WP object cache, even if the DB row didn't exist.
		// wp_cache_delete on a missing key is a no-op, but skipping it when the
		// DB row is gone while Memcached still has a stale entry causes persistent
		// stale data across requests (see #8601 for the original fix, #9639 for
		// the regression).
		wp_cache_delete( $key, 'options' );
	}

	/**
	 * Delete all known cache entries.
	 */
	public function delete_all() {
		foreach ( self::ALL_KEYS as $key ) {
			$this->delete( $key );
		}
	}

	/**
	 * 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() )
			|| $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 ) {
		// Compute the new consecutive_errors value before rebuilding the payload.
		// Each errored write increments the counter; a successful write resets it.
		// Legacy entries without the field are treated as zero.
		$consecutive_errors = 0;
		if ( $errored ) {
			$previous           = $this->get_from_cache( $key );
			$previous_count     = ( is_array( $previous ) && isset( $previous['consecutive_errors'] ) )
				? (int) $previous['consecutive_errors']
				: 0;
			$consecutive_errors = $previous_count + 1;
		}

		// 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;
		$cache_contents['consecutive_errors'] = $consecutive_errors;

		// 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'] ) {
						// Progressive backoff on repeated errors (2/5/10/15 min).
						$ttl = $this->get_errored_ttl( $cache_contents['consecutive_errors'] ?? 0 );
					} 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'] ) {
						// Progressive backoff on repeated errors (2/5/10/15 min).
						$ttl = $this->get_errored_ttl( $cache_contents['consecutive_errors'] ?? 0 );
					} 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::TRACKING_INFO_KEY:
				$ttl = $cache_contents['errored']
					? $this->get_errored_ttl( $cache_contents['consecutive_errors'] ?? 0 )
					: MONTH_IN_SECONDS;
				break;
			case self::ADDRESS_AUTOCOMPLETE_JWT_KEY:
				if ( $cache_contents['errored'] ) {
					// Retry quickly after a transient failure so address autocomplete recovers promptly.
					$ttl = 2 * MINUTE_IN_SECONDS;
				} else {
					$ttl = 12 * HOUR_IN_SECONDS;
				}
				break;
			default:
				// Default to 24h.
				$ttl = DAY_IN_SECONDS;
				break;
		}

		/**
		 * Allows filtering of the time-to-live (in seconds) for a WooPayments database cache entry.
		 *
		 * @since 4.0.0
		 *
		 * @param int    $ttl            The cache TTL in seconds.
		 * @param string $key            The cache key.
		 * @param mixed  $cache_contents The contents being cached.
		 */
		return apply_filters( 'wcpay_database_cache_ttl', $ttl, $key, $cache_contents );
	}

	/**
	 * Maps a consecutive-errors count to a TTL using a fixed backoff ladder.
	 *
	 * Ladder: 1 → 2 min, 2 → 5 min, 3 → 10 min, 4+ → 15 min. Values ≤ 0 are
	 * clamped to the first step, matching the behaviour of legacy cache entries
	 * that were written before the counter existed.
	 *
	 * @param int $consecutive_errors Number of consecutive errored writes (including the current one).
	 *
	 * @return int TTL in seconds.
	 */
	private function get_errored_ttl( int $consecutive_errors ): int {
		$ladder = [
			2 * MINUTE_IN_SECONDS,
			5 * MINUTE_IN_SECONDS,
			10 * MINUTE_IN_SECONDS,
			15 * MINUTE_IN_SECONDS,
		];

		$index = max( 0, min( count( $ladder ) - 1, $consecutive_errors - 1 ) );

		return $ladder[ $index ];
	}
}
