<?php
/**
 * @license GPL-2.0-or-later
 *
 * Modified using {@see https://github.com/BrianHenryIE/strauss}.
 */

namespace GravityKit\GravityView\Foundation\Licenses;

use Exception;
use GravityKit\GravityView\Foundation\Core;
use GravityKit\GravityView\Foundation\Exceptions\LockAcquisitionException;
use GravityKit\GravityView\Foundation\Helpers\Arr;
use GravityKit\GravityView\Foundation\Helpers\WP;
use GravityKit\GravityView\Foundation\Logger\Framework as LoggerFramework;
use GravityKit\GravityView\Foundation\Encryption\Encryption;
use GravityKit\GravityView\Foundation\Helpers\Core as CoreHelpers;
use GravityKit\GravityView\Foundation\Licenses\WP\WPUpgraderSkin;
use GravityKit\GravityView\Foundation\WP\AdminMenu;
use Plugin_Upgrader;
use stdClass;

class ProductManager {
	const EDD_PRODUCTS_API_ENDPOINT = 'https://www.gravitykit.com/edd-api/products/';

	const EDD_PRODUCTS_API_VERSION = 3;

	const PRODUCTS_DATA_CACHE_ID = Framework::ID . '/products/' . Core::VERSION;

	const PRODUCTS_DATA_CACHE_EXPIRATION = 86400; // 24 hours in seconds.

	/**
	 * Duration in seconds for the force-refresh lock window.
	 *
	 * Concurrent force-refresh requests within this window will share the same fetch operation.
	 *
	 * @since 1.7.0
	 */
	const FORCE_REFRESH_LOCK_WINDOW = 5;

	/**
	 * Maximum number of polling attempts when waiting for a concurrent fetch.
	 *
	 * Uses exponential backoff: 100ms, 200ms, 400ms, 800ms, then 1s (capped).
	 * Total wait time: ~10.5 seconds with 13 attempts.
	 *
	 * @since 1.7.0
	 */
	const FETCH_WAIT_MAX_ATTEMPTS = 13;

	/**
	 * Initial delay in microseconds for exponential backoff polling.
	 *
	 * Doubled on each attempt until FETCH_WAIT_POLL_MAX is reached.
	 *
	 * @since 1.7.0
	 */
	const FETCH_WAIT_POLL_INITIAL = 100000; // 100 milliseconds.

	/**
	 * Maximum delay in microseconds between polling attempts (exponential backoff cap).
	 *
	 * @since 1.7.0
	 */
	const FETCH_WAIT_POLL_MAX = 1000000; // 1 second.

	/**
	 * Lock timeout in seconds for product fetch operations.
	 *
	 * @since 1.7.0
	 */
	const FETCH_LOCK_TIMEOUT = 15;

	/**
	 * Brief delay in microseconds to allow other processes to complete their lock acquisition.
	 *
	 * @since 1.7.0
	 */
	const FETCH_LOCK_RACE_DELAY = 10000; // 10 milliseconds.

	/**
	 * Wait duration in microseconds after losing a lock race before checking cache.
	 *
	 * @since 1.7.0
	 */
	const FETCH_LOCK_LOST_RACE_WAIT = 500000; // 0.5 seconds.

	/**
	 * {@ProductManager} class instance.
	 *
	 * @since 1.0.0
	 *
	 * @var ProductManager|null;
	 */
	private static $_instance = null;

	/**
	 * Returns class instance.
	 *
	 * @since 1.0.0
	 *
	 * @return ProductManager
	 */
	public static function get_instance() {
		if ( is_null( self::$_instance ) ) {
			self::$_instance = new self();
		}

		return self::$_instance;
	}

	/**
	 * Initializes the class.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function init() {
		static $initialized;

		if ( $initialized ) {
			return;
		}

		add_filter( 'gk/foundation/ajax/' . Framework::AJAX_ROUTER . '/routes', [ $this, 'configure_ajax_routes' ] );

		add_action( 'gk/foundation/ajax/after', [ $this, 'on_ajax_completion' ], 10, 3 );

		add_action( 'gk/foundation/plugin-activated', [ $this, 'cleanup_products_data' ] );

		$this->update_manage_your_kit_submenu_badge_count();

		$initialized = true;
	}

	/**
	 * Configures Ajax routes handled by this class.
	 *
	 * @since 1.0.0
	 *
	 * @see   Core::process_ajax_request()
	 *
	 * @param array $routes Ajax action to class method map.
	 *
	 * @return array
	 */
	public function configure_ajax_routes( array $routes ) {
		return array_merge(
			$routes,
			[
				'install_product'    => [ $this, 'ajax_install_product' ],
				'update_product'     => [ $this, 'ajax_update_product' ],
				'delete_product'     => [ $this, 'ajax_delete_product' ],
				'activate_product'   => [ $this, 'ajax_activate_product' ],
				'deactivate_product' => [ $this, 'ajax_deactivate_product' ],
				'get_products'       => [ $this, 'ajax_get_products_data' ],
			]
		);
	}

	/**
	 * Executes various tasks upon the completion of an Ajax request.
	 *
	 * @since 1.2.0
	 *
	 * @param string $router  Ajax router that handled the request.
	 * @param string $route   Ajax route that was requested.
	 * @param array  $payload Ajax request payload.
	 *
	 * @return void
	 */
	public function on_ajax_completion( $router, $route, array $payload ): void {
		if ( Framework::AJAX_ROUTER !== $router ) {
			return;
		}

		if ( in_array( $route, [ 'install_product', 'activate_product', 'update_product' ], true ) && isset( $payload['pause_after_completion'] ) ) {
			sleep( (int) $payload['pause_after_completion'] );
		}
	}

	/**
	 * Returns the first product found based on an Ajax router request payload.
	 *
	 * @since $ver$
	 * @since 2.7.2 Renamed from get_first_project_by_payload() & added $products_data_args parameter.
	 *
	 * @param array $payload            The payload.
	 * @param array $products_data_args Optional. Arguments to pass to get_products_data().
	 *
	 * @return array|null The product object.
	 */
	private function get_first_product_by_payload( array $payload, array $products_data_args = [] ): ?array {
		$text_domains = array_filter( explode( '|', $payload['text_domain'] ?? '' ) );

		if ( ! $text_domains ) {
			return null;
		}

		$product = Arr::first(
			$this->get_products_data( $products_data_args ),
			static function ( array $product ) use ( $text_domains ) {
				return (bool) array_intersect( $text_domains, $product['text_domains'] );
			}
		);

		return $product;
	}

	/**
	 * Ajax request wrapper for the install_product() method.
	 *
	 * @since 1.0.0
	 *
	 * @param array $payload Ajax request payload.
	 *
	 * @throws Exception
	 *
	 * @return array{products: array, activation_error: null|string}
	 */
	public function ajax_install_product( array $payload ): array {
		$payload = wp_parse_args(
			$payload,
			[
				'text_domain'                 => null,
				'activate'                    => false,
				'frontend_foundation_version' => 0,
			]
		);

		if ( ! Framework::get_instance()->current_user_can( 'install_products' ) ) {
			throw new Exception( esc_html__( 'You do not have a permission to perform this action.', 'gk-gravityview' ) );
		}

		$product = $this->get_first_product_by_payload( $payload );

		if ( ! $product ) {
			throw new Exception(
				strtr(
					esc_html_x( "Product with '[text_domain]' text domain not found.", 'Placeholders inside [] are not to be translated.', 'gk-gravityview' ),
					[ '[text_domain]' => $payload['text_domain'] ]
				)
			);
		}

		$this->install_product( $product );

		// Re-fetch product after install to get updated installation status.
		$product = $this->get_first_product_by_payload( $payload, [ 'skip_request_cache' => true ] );

		$activation_error = null;

		$backend_foundation_version = Core::VERSION;

		if ( ! $product['active'] && $payload['activate'] ) {
			try {
				$this->activate_product( $product );

				// Check if the installed product comes with a newer version of the Foundation, which will be loaded if another Ajax request is made.
				$product_foundation_version = Core::get_instance()->get_plugin_foundation_version( $product['plugin_file'] );

				$backend_foundation_version = CoreHelpers::version_compare(
					Core::VERSION,
					$product_foundation_version ?? '0',
					'<'
				) ? $product_foundation_version : Core::VERSION;
			} catch ( Exception $e ) {
				$activation_error = $e->getMessage();
			}
		}

		return [
			'products'         => $this->ajax_get_products_data(),
			'activation_error' => $activation_error,
            'ui_action'        => [
                'reload' => CoreHelpers::version_compare( $backend_foundation_version, $payload['frontend_foundation_version'], '<>' ) || $product['has_admin_menu'],
            ],
		];
	}

	/**
	 * Installs a product.
	 *
	 * @since 1.0.0
	 *
	 * @param array $product Product data.
	 *
	 * @throws Exception
	 *
	 * @return void
	 */
	public function install_product( array $product ) {
		if ( ! file_exists( ABSPATH . 'wp-admin/includes/class-wp-upgrader.php' ) ) {
			throw new Exception( esc_html__( 'Unable to load core WordPress files required to install the product.', 'gk-gravityview' ) );
		}

		include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';

		$product_id            = $product['id'];
		$product_download_link = $product['download_link'];

		$license_manager = LicenseManager::get_instance();

		if ( ! $product_download_link ) {
			$licenses_data = $license_manager->get_licenses_data();

			foreach ( $licenses_data as $key => $license_data ) {
				if ( $license_manager->is_expired_license( $license_data['expiry'] ) || empty( $license_data['products'] ) || ! isset( $license_data['products'][ $product_id ] ) ) {
					continue;
				}

				try {
					$license = $license_manager->check_license( $key );
				} catch ( Exception $e ) {
					LoggerFramework::get_instance()->warning( "Unable to verify license key {$key} when installing product ID {$product_id}: " . $e->getMessage() );

					continue;
				}

				if ( empty( $license['products'][ $product_id ]['download'] ) ) {
					continue;
				}

				$product_download_link = $license['products'][ $product_id ]['download'];

				break;
			}
		}

		if ( ! $product_download_link ) {
			throw new Exception( esc_html__( 'Unable to locate product download link.', 'gk-gravityview' ) );
		}

		$installer = new Plugin_Upgrader( new WPUpgraderSkin() );

		try {
			$installer->install( $product_download_link, [ 'overwrite_package' => true ] );
		} catch ( Exception $e ) {
			$error = join(
				' ',
				[
					esc_html__( 'Installation failed.', 'gk-gravityview' ),
					$e->getMessage(),
				]
			);

			throw new Exception( $error );
		}

		if ( ! $installer->result ) {
			throw new Exception( esc_html__( 'Installation failed.', 'gk-gravityview' ) );
		}
	}

	/**
	 * Ajax request wrapper for the update_product() method.
	 *
	 * @since 1.0.0
	 *
	 * @param array $payload Ajax request payload.
	 *
	 * @throws Exception
	 *
	 * @return array{products: array, activation_error: null|string}
	 */
	public function ajax_update_product( array $payload ): array {
		$payload = wp_parse_args(
			$payload,
			[
				'text_domain'                 => null,
				'frontend_foundation_version' => 0,
			]
		);

		if ( ! Framework::get_instance()->current_user_can( 'update_products' ) ) {
			throw new Exception( esc_html__( 'You do not have a permission to perform this action.', 'gk-gravityview' ) );
		}

		$product = $this->get_first_product_by_payload( $payload );

		if ( ! $product ) {
			throw new Exception(
				strtr(
					esc_html_x( "Product with '[text_domain]' text domain not found.", 'Placeholders inside [] are not to be translated.', 'gk-gravityview' ),
					[ '[text_domain]' => $payload['text_domain'] ]
				)
			);
		}

		$this->update_product( $product );

		$backend_foundation_version = Core::VERSION;

		$activation_error = null;

		// After an update, the product is deactivated and needs to be reactivated. Activate it if it was active before the update.
		if ( $product['active'] ) {
			try {
				$this->activate_product( $product );

				// Check if the updated product comes with a newer version of the Foundation, which will be loaded if another Ajax request is made.
				$product_foundation_version = Core::get_instance()->get_plugin_foundation_version( $product['plugin_file'], true );

				$backend_foundation_version = CoreHelpers::version_compare(
					Core::VERSION,
					$product_foundation_version,
					'<'
				) ? $product_foundation_version : Core::VERSION;
			} catch ( Exception $e ) {
				$activation_error = $e->getMessage();
			}
		}

        return [
            'products'         => $this->ajax_get_products_data(),
            'activation_error' => $activation_error,
            'ui_action'        => [
                'reload' => CoreHelpers::version_compare( $backend_foundation_version, $payload['frontend_foundation_version'], '<>' ) || ( $product['has_admin_menu'] && $product['active'] && ! $activation_error ),
            ],
        ];
	}

	/**
	 * Updates a product.
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Made the $product parameter an array of product data.
	 *
	 * @param array $product Product data.
	 *
	 * @throws Exception
	 *
	 * @return void
	 */
	public function update_product( array $product ) {
		if ( ! file_exists( ABSPATH . 'wp-admin/includes/class-wp-upgrader.php' ) ) {
			throw new Exception( esc_html__( 'Unable to load core WordPress files required to install the product.', 'gk-gravityview' ) );
		}

		include_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';

		// This is an edge case when for some reason the update_plugins transient is not set or the product is not marked as needing an update.
		$update_plugins_transient_filter = function ( $transient_data ) use ( $product ) {
			if ( ! $transient_data ) {
				$transient_data          = new stdClass();
				$transient_data->checked = [ $product['path'] => $product['installed_version'] ];
			}

			return EDD::get_instance()->check_for_product_updates( $transient_data );
		};

		// Tampering with the user-agent header (e.g., done by the WordPress Classifieds Plugin) breaks the update process.
		$lock_user_agent_header = function ( $args, $url ) {
			if ( strpos( $url, 'gravitykit.com' ) !== false ) {
				$args['user-agent'] = 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url();
			}

			return $args;
		};

		$updater = new Plugin_Upgrader( new WPUpgraderSkin() );

		try {
			add_filter( 'pre_site_transient_update_plugins', $update_plugins_transient_filter );
			add_filter( 'http_request_args', $lock_user_agent_header, 100, 2 );

			$updater->upgrade( $product['path'] );

			remove_filter( 'pre_site_transient_update_plugins', $update_plugins_transient_filter );
			remove_filter( 'http_request_args', $lock_user_agent_header, 100 );
		} catch ( Exception $e ) {
			$error = join(
				' ',
				[
					esc_html__( 'Update failed.', 'gk-gravityview' ),
					$updater->strings[ $e->getMessage() ] ?? $e->getMessage(),
				]
			);

			throw new Exception( $error );
		}

		if ( ! $updater->result ) {
			throw new Exception( esc_html__( 'Installation failed.', 'gk-gravityview' ) );
		}
	}

	/**
	 * Ajax request wrapper for the delete_product() method.
	 *
	 * @since 1.0.0
	 *
	 * @param array $payload Ajax request payload.
	 *
	 * @throws Exception
	 *
	 * @return array{products: array}
	 */
	public function ajax_delete_product( array $payload ): array {
		$payload = wp_parse_args(
			$payload,
			[
				'text_domain' => null,
			]
		);

		if ( ! Framework::get_instance()->current_user_can( 'delete_products' ) ) {
			throw new Exception( esc_html__( 'You do not have a permission to perform this action.', 'gk-gravityview' ) );
		}

		$product = $this->get_first_product_by_payload( $payload );

		if ( ! $product ) {
			throw new Exception(
				strtr(
					esc_html_x( "Product with '[text_domain]' text domain not found.", 'Placeholders inside [] are not to be translated.', 'gk-gravityview' ),
					[ '[text_domain]' => $payload['text_domain'] ]
				)
			);
		}

		if ( $product['active'] ) {
			throw new Exception(
				esc_html__( 'Product must be deactivated before it can be deleted.', 'gk-gravityview' )
			);
		}

		$this->delete_product( $product );

		return [
			'products' => $this->ajax_get_products_data(),
		];
	}

	/**
	 * Deletes a product.
	 *
	 * @since 1.2.0
	 *
	 * @param array $product Product data.
	 *
	 * @throws Exception
	 *
	 * @return void
	 */
	public function delete_product( array $product ) {
		$clear_cache_after_delete = function ( $plugin_path ) use ( $product ) {
			if ( $plugin_path !== $product['path'] ) {
				return;
			}

			wp_cache_delete( 'plugins', 'plugins' );
		};

		add_action( 'delete_plugin', $clear_cache_after_delete );

		$result = delete_plugins( [ $product['path'] ] );

		remove_action( 'delete_plugin', $clear_cache_after_delete );

		if ( is_wp_error( $result ) ) {
			throw new Exception( $result->get_error_message() );
		}

		if ( is_null( $result ) ) {
			throw new Exception( esc_html__( 'Could not delete the product due to missing filesystem credentials.', 'gk-gravityview' ) );
		}
	}

	/**
	 * Ajax request wrapper for the activate_product() method.
	 *
	 * @since 1.0.0
	 *
	 * @param array $payload Ajax request payload.
	 *
	 * @throws Exception
	 *
	 * @return array{products: array}
	 */
	public function ajax_activate_product( array $payload ): array {
		$payload = wp_parse_args(
			$payload,
			[
				'text_domain'                 => null,
				'frontend_foundation_version' => 0,
			]
		);

		if ( ! Framework::get_instance()->current_user_can( 'activate_products' ) ) {
			throw new Exception( esc_html__( 'You do not have a permission to perform this action.', 'gk-gravityview' ) );
		}

		$product = $this->get_first_product_by_payload( $payload ) ?? CoreHelpers::get_installed_plugin_by_text_domain( $payload['text_domain'] );

		if ( ! $product ) {
			throw new Exception(
				strtr(
					esc_html_x( "Product with '[text_domain]' text domain not found.", 'Placeholders inside [] are not to be translated.', 'gk-gravityview' ),
					[ '[text_domain]' => $payload['text_domain'] ]
				)
			);
		}

		$this->activate_product( $product );

		// Check if the activated product comes with a newer version of the Foundation, which will be loaded if another Ajax request is made.
		$product_foundation_version = Core::get_instance()->get_plugin_foundation_version( $product['plugin_file'] );

		$backend_foundation_version = CoreHelpers::version_compare(
			Core::VERSION,
			$product_foundation_version ?? '0',
			'<'
		) ? $product_foundation_version : Core::VERSION;

        return [
            'products'  => $this->ajax_get_products_data(),
            'ui_action' => [
                'reload' => CoreHelpers::version_compare( $backend_foundation_version, $payload['frontend_foundation_version'], '<>' ) || $product['has_admin_menu'],
            ],
        ];
	}

	/**
	 * Activates a product.
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Made the $product parameter an array of product data.
	 *
	 * @param array $product Product data.
	 *
	 * @throws Exception
	 *
	 * @return void
	 */
	public function activate_product( array $product ) {
		if ( $this->is_product_active_in_current_context( $product['path'] ) ) {
			throw new Exception( esc_html__( 'Product is already active.', 'gk-gravityview' ) );
		}

		$result = activate_plugin( $product['path'], '', CoreHelpers::is_network_admin() );

		if ( is_wp_error( $result ) ) {
			throw new Exception(
				strtr(
					esc_html_x( 'Could not activate the product. [error]', 'Placeholders inside [] are not to be translated.', 'gk-gravityview' ),
					[ '[error]' => $result->get_error_message() ]
				)
			);
		}
	}

	/**
	 * Ajax request wrapper for the deactivate_product() method.
	 *
	 * @since 1.0.0
	 *
	 * @param array $payload Ajax request payload.
	 *
	 * @throws Exception
	 *
	 * @return array{products: array}
	 */
	public function ajax_deactivate_product( array $payload ): array {
		$payload = wp_parse_args(
			$payload,
			[
				'text_domain'                 => null,
				'frontend_foundation_version' => 0,
			]
		);

		if ( ! Framework::get_instance()->current_user_can( 'deactivate_products' ) ) {
			throw new Exception( esc_html__( 'You do not have a permission to perform this action.', 'gk-gravityview' ) );
		}

		$product = $this->get_first_product_by_payload( $payload );

		if ( ! $product ) {
			throw new Exception(
				strtr(
					esc_html_x( "Product with '[text_domain]' text domain not found.", 'Placeholders inside [] are not to be translated.', 'gk-gravityview' ),
					[ '[text_domain]' => $payload['text_domain'] ]
				)
			);
		}

		$this->deactivate_product( $product );

		$backend_foundation_version = Core::get_instance()->get_latest_foundation_version_from_registered_plugins( $product['text_domain'] );

        return [
            'products'  => $this->ajax_get_products_data(),
            'ui_action' => [
                'reload'   => CoreHelpers::version_compare( $backend_foundation_version, $payload['frontend_foundation_version'], '<>' ) || $product['has_admin_menu'],
                'redirect' => ! $backend_foundation_version ? [
                    'url'            => CoreHelpers::is_network_admin() ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' ),
                    'loader_title'   => esc_html__( 'Redirecting to the Plugins page…', 'gk-gravityview' ),
                    'loader_message' => esc_html__( 'Manage Your Kit functionality is no longer available.', 'gk-gravityview' ),
                ] : false,
            ],
        ];
	}

	/**
	 * Deactivates a product.
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Made the $product parameter an array of product data.
	 *
	 * @param array $product Product data.
	 *
	 * @throws Exception
	 *
	 * @return void
	 */
	public function deactivate_product( array $product ) {
		if ( ! $this->is_product_active_in_current_context( $product['path'] ) ) {
			throw new Exception( esc_html__( 'Product in not active.', 'gk-gravityview' ) );
		}

		deactivate_plugins( $product['path'], false, CoreHelpers::is_network_admin() );

		// @phpstan-ignore-next-line
		if ( $this->is_product_active_in_current_context( $product['path'] ) ) {
			throw new Exception( esc_html__( 'Could not deactivate the product.', 'gk-gravityview' ) );
		}
	}

	/**
	 * Returns a list of all GravityKit products from the API grouped by category (e.g., plugins, extensions, etc.).
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Result is no longer grouped by category and is now keyed by product ID.
	 * @since 1.7.0 Added request locking to prevent concurrent API calls.
	 *
	 * @param bool $force_refresh (optional) Whether to bypass cache and fetch fresh data. Default: false.
	 *
	 * @throws LockAcquisitionException When unable to acquire lock for product fetch operation.
	 * @throws Exception
	 *
	 * @return array
	 */
	public function get_remote_products( bool $force_refresh = false ): array {
		$cache_key = self::PRODUCTS_DATA_CACHE_ID;
		$lock_key  = $this->get_fetch_lock_key( $force_refresh );

		// Try to wait for any concurrent fetch to complete.
		if ( ! $force_refresh ) {
			$cached_result = $this->wait_for_concurrent_fetch( $lock_key, $cache_key );
			if ( false !== $cached_result ) {
				return $cached_result;
			}
		}

		// Acquire lock or handle race condition.
		$lock_token = $this->acquire_fetch_lock( $lock_key );

		// Delete cache after acquiring lock to prevent race condition.
		if ( $force_refresh && false !== $lock_token ) {
			WP::delete_transient( $cache_key );
		}

		// If we lost the lock race, check if cache is now available.
		if ( false === $lock_token ) {
			$cached_result = $this->get_cached_products( $cache_key );
			if ( false !== $cached_result ) {
				return $cached_result;
			}

			// Lock not acquired and no cache available - do not proceed to fetch.
			throw LockAcquisitionException::failed(
				$lock_key,
				esc_html__( 'Unable to fetch product data at this time. Please try again.', 'gk-gravityview' ),
				[
					'cache_key' => $cache_key,
					'reason'    => 'Failed to acquire distributed lock for concurrent fetch prevention',
				]
			);
		}

		try {
			$normalized_products = $this->fetch_and_normalize_products();
		} catch ( Exception $e ) {
			$this->release_fetch_lock( $lock_key, $lock_token );
			throw $e;
		}

		// Release lock now that we're done.
		$this->release_fetch_lock( $lock_key, $lock_token );

		return $normalized_products;
	}

	/**
	 * Generates the lock key for product fetching operations.
	 *
	 * @since 1.7.0
	 *
	 * @param bool $force_refresh Whether this is a force refresh operation.
	 *
	 * @return string
	 */
	private function get_fetch_lock_key( bool $force_refresh ): string {
		$lock_key = self::PRODUCTS_DATA_CACHE_ID . '/fetch_lock';

		// For force refresh, add timestamp to ensure fresh fetch while allowing concurrent requests to share it.
		if ( $force_refresh ) {
			$lock_key .= '_' . floor( time() / self::FORCE_REFRESH_LOCK_WINDOW );
		}

		return $lock_key;
	}

	/**
	 * Waits for a concurrent fetch operation to complete and returns cached result if available.
	 *
	 * @since 1.7.0
	 *
	 * @param string $lock_key  The lock key to monitor.
	 * @param string $cache_key The cache key to check for results.
	 *
	 * @return array|false Array of products if found in cache, false otherwise.
	 */
	private function wait_for_concurrent_fetch( string $lock_key, string $cache_key ) {
		$initial_lock_value = WP::get_transient( $lock_key );

		if ( false === $initial_lock_value ) {
			return false;
		}

		// Another process is fetching. Poll for the cached result using exponential backoff.
		$attempt    = 0;
		$poll_delay = self::FETCH_WAIT_POLL_INITIAL;

		while ( $attempt < self::FETCH_WAIT_MAX_ATTEMPTS ) {
			// Exponential backoff: start at 100ms, double each time, cap at 1s.
			usleep( $poll_delay );

			$poll_delay = min( $poll_delay * 2, self::FETCH_WAIT_POLL_MAX );

			// Check if the fetch completed and data is now cached.
			$cached_result = $this->get_cached_products( $cache_key );
			if ( false !== $cached_result ) {
				LoggerFramework::get_instance()->debug( 'Using cached products from concurrent request' );
				return $cached_result;
			}

			// Check if lock was released (other process finished or failed).
			if ( false === WP::get_transient( $lock_key ) ) {
				return false;
			}

			$attempt++;
		}

		// If we timed out and lock still exists, attempt to release it.
		// release_fetch_lock will verify ownership before releasing.
		if ( false !== WP::get_transient( $lock_key ) ) {
			LoggerFramework::get_instance()->warning( 'Stale fetch lock detected after timeout, attempting to release' );
			$this->release_fetch_lock( $lock_key, $initial_lock_value );
		}

		return false;
	}

	/**
	 * Retrieves cached products data if valid.
	 *
	 * @since 1.7.0
	 *
	 * @param string $cache_key The cache key to check.
	 *
	 * @return array|false Array of products if found and valid, false otherwise.
	 */
	private function get_cached_products( string $cache_key ) {
		$cached_products = WP::get_transient( $cache_key );

		if ( ! $cached_products ) {
			return false;
		}

		$decoded = json_decode( $cached_products, true );

		if ( empty( $decoded['raw'] ) || ! is_array( $decoded['raw'] ) ) {
			return false;
		}

		return $decoded['raw'];
	}

	/**
	 * Attempts to acquire a lock for product fetching.
	 *
	 * @since 1.7.0
	 *
	 * @param string $lock_key The lock key to acquire.
	 *
	 * @return string|false The unique lock token if acquired, false if another process has the lock.
	 */
	private function acquire_fetch_lock( string $lock_key ) {
		// Attempt to acquire lock using a unique value to detect race conditions.
		$our_lock_value = uniqid( 'gk_fetch_', true );
		WP::set_transient( $lock_key, $our_lock_value, self::FETCH_LOCK_TIMEOUT );

		// Verify we actually got the lock (handles race condition).
		usleep( self::FETCH_LOCK_RACE_DELAY );

		if ( WP::get_transient( $lock_key ) === $our_lock_value ) {
			return $our_lock_value;
		}

		// Another process won the race. Wait briefly to give it time to cache results.
		usleep( self::FETCH_LOCK_LOST_RACE_WAIT );

		return false;
	}

	/**
	 * Safely releases a lock by verifying ownership before deletion.
	 *
	 * Only deletes the lock if the current lock value matches our token,
	 * preventing accidental deletion of locks acquired by other processes.
	 *
	 * @since 1.7.0
	 *
	 * @param string $lock_key   The lock key to release.
	 * @param string $lock_token The unique token that was returned when the lock was acquired.
	 *
	 * @return void
	 */
	private function release_fetch_lock( string $lock_key, string $lock_token ): void {
		$current_lock_value = WP::get_transient( $lock_key );

		// Only delete the lock if we still own it.
		if ( $current_lock_value !== $lock_token ) {
			return;
		}

		WP::delete_transient( $lock_key );
	}

	/**
	 * Fetches products from the API and normalizes them.
	 *
	 * @since 1.7.0
	 *
	 * @throws Exception
	 *
	 * @return array
	 */
	private function fetch_and_normalize_products(): array {
		$response = Helpers::query_api(
			self::EDD_PRODUCTS_API_ENDPOINT,
			[
				'api_version' => self::EDD_PRODUCTS_API_VERSION,
				'bust_cache'  => time(),
			]
		);

		$products = Arr::get( $response, 'products', [] );

		if ( empty( $response ) || empty( $products ) ) {
			throw new Exception( esc_html__( 'Invalid product information received from the API.', 'gk-gravityview' ) );
		}

		$normalized_products = [];

		foreach ( $products as $product ) {
			$product_id = Arr::get( $product, 'info.id' );
			$sections   = unserialize( Arr::get( $product, 'readme.sections', [] ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
			$banners    = unserialize( Arr::get( $product, 'readme.banners', [] ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
			$icons      = unserialize( Arr::get( $product, 'readme.icons', [] ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize

			if ( ! Arr::get( $product, 'info.category_slug' ) || 'bundles' === Arr::get( $product, 'info.category_slug' ) ) {
				continue;
			}

			$product_schema = $this->get_product_schema();

			$normalized_products[ $product_id ] = $this->normalize_product_data(
				[
					'id'                 => $product_id,
					'slug'               => Arr::get( $product, 'info.slug', $product_schema['slug'] ),
					'category_name'      => Arr::get( $product, 'info.category_name', $product_schema['category_name'] ),
					'category_slug'      => Arr::get( $product, 'info.category_slug', $product_schema['category_slug'] ),
					'category_order'     => Arr::get( $product, 'info.category_order', $product_schema['category_order'] ),
					'text_domain'        => Arr::get( $product, 'info.text_domain', $product_schema['text_domain'] ),
					'text_domain_legacy' => Arr::get( $product, 'info.text_domain_legacy', $product_schema['text_domain_legacy'] ),
					'has_admin_menu'     => Arr::get( $product, 'info.has_admin_menu', $product_schema['has_admin_menu'] ),
					'hidden'             => Arr::get( $product, 'info.hidden', $product_schema['hidden'] ),
					'free'               => Arr::get( $product, 'info.free', $product_schema['free'] ),
					'third_party'        => Arr::get( $product, 'info.third_party', $product_schema['third_party'] ),
					'coming_soon'        => Arr::get( $product, 'info.coming_soon', $product_schema['coming_soon'] ),
					'name'               => Arr::get( $product, 'info.title', $product_schema['name'] ),
					'excerpt'            => Arr::get( $product, 'info.excerpt', $product_schema['excerpt'] ),
					'buy_link'           => esc_url_raw( $product['info']['buy_url'] ?? $product_schema['buy_link'] ),
					'link'               => esc_url_raw( $product['info']['link'] ?? $product_schema['link'] ),
					'download_link'      => esc_url_raw( $product['info']['download_link'] ?? $product_schema['download_link'] ),
					'icon'               => esc_url_raw( $product['info']['icon'] ?? $product_schema['icon'] ),
					'icons'              => [
						'1x' => esc_url_raw( $icons['1x'] ?? $product_schema['icons']['1x'] ),
						'2x' => esc_url_raw( $icons['2x'] ?? $product_schema['icons']['2x'] ),
					],
					'banners'            => [
						'low'  => esc_url_raw( $banners['low'] ?? $product_schema['banners']['low'] ),
						'high' => esc_url_raw( $banners['high'] ?? $product_schema['banners']['low'] ),
					],
					'sections'           => [
						'description' => Arr::get( $sections, 'description', $product_schema['sections']['description'] ),
						'changelog'   => $this->truncate_product_changelog(
							Arr::get( $sections, 'changelog', $product_schema['sections']['changelog'] ),
							esc_url_raw( $product['info']['link'] ?? $product_schema['link'] )
						),
					],
					'server_version'     => Arr::get( $product, 'licensing.version', $product_schema['server_version'] ),
					'modified_date'      => Arr::get( $product, 'info.modified_date', $product_schema['modified_date'] ),
					'docs'               => esc_url_raw( $product['info']['docs_url'] ?? $product_schema['docs'] ),
					'dependencies'       => Arr::get( $product, 'dependencies', $product_schema['dependencies'] ),
				]
			);
		}

		return $normalized_products;
	}

	/**
	 * Truncates the product changelog to display only the specified number of most recent entries.
	 *
	 * @since 1.0.11
	 *
	 * @param string $changelog              Product changelog.
	 * @param string $product_url            Product URL.
	 * @param int    $max_changelog_entries  (optional) Number of entries to display. Default: 3.
	 * @param bool   $link_to_full_changelog (optional) Display a link to the full changelog on GravityKit's website. Default: true.
	 *
	 * @return string
	 */
	public function truncate_product_changelog( $changelog, $product_url, $max_changelog_entries = 3, $link_to_full_changelog = true ) {
		$changelog_pattern = '~(<p><strong>\d+.*?on.*?(?=<p><strong>\d+.*?on|$))~s';

		preg_match_all( $changelog_pattern, $changelog, $parsed_changelog );

		if ( empty( $parsed_changelog[0] ) ) {
			return $changelog;
		}

		$changelog = '';

		$truncated_changelog = array_slice( $parsed_changelog[0], 0, $max_changelog_entries );

		if ( count( $parsed_changelog[0] ) > count( $truncated_changelog ) && $link_to_full_changelog ) {
			$truncated_changelog[] = sprintf(
				'<p><a href="%s#changelog" target="_blank">%s</a></strong></p><br><br><br>', // 3 line breaks are required for this line to be displayed correctly above the fixed modal window footer.
				$product_url,
				esc_html__( 'View full changelog', 'gk-gravityview' )
			);
		}

		foreach ( $truncated_changelog as $changelog_entry ) {
			$modified_changelog_entry = $changelog_entry;
			$modified_changelog_entry = preg_replace( '~<p><strong>(\d+.*?on.*?)</strong></p>~s', '<h4>$1</h4>', $modified_changelog_entry, 1 );
			$modified_changelog_entry = preg_replace( '~<a~s', '<a class="gk-link"', $modified_changelog_entry );
			$changelog               .= $modified_changelog_entry;
		}

		return $changelog;
	}

	/**
	 * Ajax request wrapper for the {@see get_products_data()} method.
	 *
	 * @since 1.0.0
	 *
	 * @param array $payload Ajax request payload.
	 *
	 * @throws Exception
	 *
	 * @return array
	 */
	public function ajax_get_products_data( array $payload = [] ) {
		if ( ! Framework::get_instance()->current_user_can( 'view_products' ) ) {
			throw new Exception( esc_html__( 'You do not have a permission to perform this action.', 'gk-gravityview' ) );
		}

		$payload = wp_parse_args(
			$payload,
			[
				'skip_remote_cache'  => false,
				'skip_request_cache' => true,
			]
		);

		$products = $this->get_products_data( $payload );

		$excluded_properties = [
			'path',
			'plugin_file',
			'dependencies',
			'text_domain_legacy',
			'text_domains',
			'modified_date',
			'icons',
			'banners',
		];

		foreach ( $products as $key => &$product ) {
			// Unset properties that are not needed in the UI.
			foreach ( $excluded_properties as $property ) {
				if ( isset( $product[ $property ] ) ) {
					unset( $product[ $property ] );
				}
			}

			// Hide products that are not meant to be displayed in the UI.
			if ( $product['hidden'] ) {
				unset( $products[ $key ] );

				continue;
			}

			// Encrypt license keys.
			$product['licenses'] = array_map(
				function ( $key ) {
					return Encryption::get_instance()->encrypt( $key, false, Core::get_request_unique_string() );
				},
				$product['licenses']
			);
		}

		return array_values( $products );
	}

	/**
	 * Returns a list of all GravityKit products with associated installation/activation/licensing data.
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Result is now keyed by product's text domain.
	 *
	 * @param array $args (optional) Additional arguments. Default: ['skip_cache_remote' => false, 'skip_request_cache' => false, 'key_by' => 'text_domain'].
	 *
	 * @return array
	 */
	public function get_products_data( array $args = [] ) {
		static $_cached_products_data;

		$args = wp_parse_args(
			$args,
			[
				'skip_remote_cache'  => false, // If true, products will be fetched from the API even if they are cached locally.
				'skip_request_cache' => false, // If true, products data will be updated with the most recent changes during the same request.
				'key_by'             => 'text_domain',
			]
		);

		if ( ! $args['skip_remote_cache'] && ! $args['skip_request_cache'] && $_cached_products_data ) {
			return 'text_domain' === $args['key_by'] ? $_cached_products_data : $this->key_products_by_property( $_cached_products_data, $args['key_by'] );
		}

		$products = ! $args['skip_remote_cache'] ? ( WP::get_transient( self::PRODUCTS_DATA_CACHE_ID ) ?: null ) : null;

		if ( $products && ! is_array( $products ) ) { // Backward compatibility for serialized data (used in earlier Foundation versions).
			$products = json_decode( $products, true );
			$products = is_array( $products ) ? $products : null;
		}

		if ( is_null( $products ) ) {
			$products = [
				'raw'                    => [],
				'normalized'             => [],
				'installed_plugins_hash' => null,
				'licenses_hash'          => null,
			];

			try {
				$products['raw'] = $this->get_remote_products( $args['skip_remote_cache'] );
			} catch ( LockAcquisitionException $e ) {
				// Lock acquisition failed - another instance is fetching.
				// Try to use stale transient cache and re-normalize it with current data.
				LoggerFramework::get_instance()->warning(
					'Product fetch skipped due to lock contention: ' . $e->getMessage(),
					$e->get_data()
				);

				// Attempt to retrieve full cache structure from transient.
				$stale_products = WP::get_transient( self::PRODUCTS_DATA_CACHE_ID );

				if ( $stale_products ) {
					if ( ! is_array( $stale_products ) ) {
						$stale_products = json_decode( $stale_products, true );
					}

					if ( ! empty( $stale_products['raw'] ) ) {
						// Use stale cache but continue to normalization to ensure installation status,
						// licenses, and dependencies are current.
						$products = $stale_products;
					}
				}
				// If no stale cache available, $products remains with empty 'raw' array from line 1150-1155,
				// and the code below will handle empty products appropriately.
			} catch ( Exception $e ) {
				// Actual API/fetch error - log but continue to cache for retry logic.
				LoggerFramework::get_instance()->error( 'Unable to get products from the API: ' . $e->getMessage() );
			}

			// Only cache if we have data (either fresh or from previous cache).
			if ( ! empty( $products['raw'] ) ) {
				WP::set_transient(
					self::PRODUCTS_DATA_CACHE_ID,
					wp_json_encode( $products ),
					self::PRODUCTS_DATA_CACHE_EXPIRATION
				);
			}
		}

		if ( empty( $products['raw'] ) ) {
			$_cached_products_data = [];

			return $_cached_products_data;
		}

		$installed_plugins_hash = md5( wp_json_encode( CoreHelpers::get_installed_plugins( $args['skip_request_cache'] ) ) ?: '' );
		$licenses_hash          = md5( wp_json_encode( LicenseManager::get_instance()->get_licenses_data() ) ?: '' );

		// If the installed plugins haven't changed since the last request, return the cached products data to prevent re-validating dependencies, etc.
		if ( $installed_plugins_hash === $products['installed_plugins_hash'] && $licenses_hash === $products['licenses_hash'] ) {
			$_cached_products_data = $products['normalized'];

			return 'text_domain' === $args['key_by'] ? $_cached_products_data : $this->key_products_by_property( $_cached_products_data, $args['key_by'] );
		} else {
			$products['installed_plugins_hash'] = $installed_plugins_hash;
			$products['licenses_hash']          = $licenses_hash;
		}

		$product_license_map = LicenseManager::get_instance()->get_product_license_map();

		$products_history = ProductHistoryManager::get_instance()->get_products_history();

		$products['normalized'] = [];

		// Supplement API response with additional data that can change between or during requests (e.g., activation status, etc.).
		foreach ( $products['raw'] as $product ) {
			if ( ! isset( $product['text_domain'] ) ) {
				LoggerFramework::get_instance()->warning( "Unable to get text domain for {$product['name']}: " . wp_json_encode( $product ) );

				continue;
			}

			$installed_product = CoreHelpers::get_installed_plugin_by_text_domain( $product['text_domains'] );

			/**
			 * Sets link to the product settings page.
			 *
			 * @filter `gk/foundation/settings/{$product_slug}/settings-url`
			 *
			 * @since  1.0.3
			 *
			 * @param string $settings_url URL to the product settings page.
			 */
			$product_settings_url = apply_filters( "gk/foundation/settings/{$product['slug']}/settings-url", '' );

			$normalized_product = array_merge(
				$product,
				[
					'id'                => $product['id'],
					'text_domain'       => $installed_product['text_domain'] ?? $product['text_domain'],
					'installed'         => ! is_null( $installed_product ),
					'installed_version' => $installed_product['version'] ?? $product['installed_version'],
					'active'            => $installed_product['active'] ?? $product['active'],
					'update_available'  => $installed_product && CoreHelpers::version_compare( $installed_product['version'], $product['server_version'], '<' ),
					'path'              => $installed_product['path'] ?? $product['path'],
					'plugin_file'       => $installed_product['plugin_file'] ?? $product['plugin_file'],
					'network_activated' => $installed_product['network_activated'] ?? $product['network_activated'],
					'licenses'          => $product_license_map[ $product['id'] ] ?? $product['licenses'],
					'settings'          => esc_url_raw( $product_settings_url ),
					'has_git_folder'    => $installed_product && file_exists( dirname( $installed_product['plugin_file'] ) . '/.git' ),
					'history'           => $products_history[ $product['text_domain'] ] ?? [],
				]
			);

			$products['normalized'][ $normalized_product['text_domain'] ] = $normalized_product;
		}

		/**
		 * Modifies products data object.
		 *
		 * @filter `gk/foundation/products/data`
		 *
		 * @since  1.0.3
		 *
		 * @param array $products Products data.
		 * @param array $args     Additional arguments passed to the get_products_data() method.
		 */
		$products['normalized'] = apply_filters( 'gk/foundation/products/data', $products['normalized'], $args );

		$product_dependency_checker = new ProductDependencyChecker( $products['normalized'] );

		foreach ( $products['normalized'] as &$product ) {
			$product['required_by'] = $product_dependency_checker->is_a_dependency_of_any_product( $product['text_domain'], true ) ?: [];

			$product_versions_to_check = [];

			// We need to check both installed and server versions for dependencies, for these can be different (e.g, an updated version may have new dependencies).
			if ( $product['installed'] && $product['installed_version'] ) {
				$product_versions_to_check[] = $product['installed_version'];
			}

			if ( ( ! $product['installed'] || $product['update_available'] ) && $product['server_version'] ) {
				$product_versions_to_check[] = $product['server_version'];
			}

			foreach ( $product_versions_to_check as $version ) {
				$result = $product_dependency_checker->check_dependencies( $product['text_domain'], $version );

				$product['checked_dependencies'][ $version ] = $result;

				if ( $result['status'] ) {
					continue;
				}

				// If the product only has unmet plugin dependencies, get the sequence of actions required to resolve them.
				if ( empty( $result['unmet']['system'] ) ) {
					try {
						$product['checked_dependencies'][ $version ]['resolution_sequence'] = $product_dependency_checker->get_product_dependency_resolution_sequence( $product['text_domain'], $version );
					} catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
						// No need to do anything here since dependencies can't be satisfied.
					}
				}
			}
		}

		$_cached_products_data = $products['normalized'];

		WP::set_transient(
			self::PRODUCTS_DATA_CACHE_ID,
			wp_json_encode( $products ),
			self::PRODUCTS_DATA_CACHE_EXPIRATION
		);

		return 'text_domain' === $args['key_by'] ? $products['normalized'] : $this->key_products_by_property( $products['normalized'], $args['key_by'] );
	}

	/**
	 * Keys products object by the specified product property.
	 *
	 * @since 1.2.0
	 *
	 * @param array  $products Products data.
	 * @param string $key_by   Property to key the return object by.
	 *
	 * @return array
	 */
	public function key_products_by_property( $products, $key_by ) {
		$keyed_products = [];

		foreach ( $products as $product ) {
			if ( array_key_exists( $key_by, $product ) && '' !== $product[ $key_by ] && ! is_array( $product[ $key_by ] ) ) {
				$keyed_products[ $product[ $key_by ] ] = $product;
			}
		}

		unset( $keyed_products[''] );

		return $keyed_products;
	}

	/**
	 * Checks if plugin is activated in the current context (network or site).
	 *
	 * @since 1.0.0
	 *
	 * @param string $plugin_path Plugin path.
	 *
	 * @return bool
	 */
	public function is_product_active_in_current_context( $plugin_path ) {
		return CoreHelpers::is_network_admin() ? is_plugin_active_for_network( $plugin_path ) : is_plugin_active( $plugin_path );
	}

	/**
	 * Optionally updates the Manage Your Kit submenu badge count if any of the products have newer versions available.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function update_manage_your_kit_submenu_badge_count() {
		if ( ! AdminMenu::should_initialize() ) {
			return;
		}

		if ( ! Framework::get_instance()->current_user_can( 'install_products' ) ) {
			return;
		}

		try {
			$products_data = $this->get_products_data();
		} catch ( Exception $e ) {
			LoggerFramework::get_instance()->warning( 'Unable to get products when adding a badge count for products with updates.' );

			return;
		}

		$update_count = 0;

		foreach ( $products_data as $product ) {
			if ( $product['third_party'] || $product['hidden'] ) {
				continue;
			}

			if ( $product['update_available'] ) {
				$update_count++;
			}
		}

		if ( ! $update_count ) {
			return;
		}

		add_filter(
			'gk/foundation/admin-menu/submenu/' . Framework::ID . '/counter',
			function ( $count ) use ( $update_count ) {
				return (int) $count + $update_count;
			}
		);
	}

	/**
	 * Returns product data schema used in the UI and elsewhere.
	 *
	 * @since 1.2.0
	 *
	 * @return array
	 */
	public function get_product_schema() {
		return [
			// phpcs:disable Squiz.PHP.CommentedOutCode.Found
			// EDD API properties.
			'id'                   => null,        // Integer. Product ID: $product['info']['id'].
			'slug'                 => '',          // String. Product slug: $product['info']['slug'].
			'category_name'        => '',          // String. Product category name: $product['info']['category_name'].
			'category_slug'        => '',          // String. Product category slug: $product['info']['category_slug'].
			'category_order'       => '',          // String. Product category slug: $product['info']['category_order'].
			'text_domain'          => '',          // String. Product text domain: $product['info']['text_domain'].
			'text_domain_legacy'   => '',          // String. Product legacy text domain(s) separated by a pipe: $product['info']['text_domain_legacy'].
			'text_domains'         => [],          // Array. Combined text domains (current + legacy) for lookup/matching.
			'has_admin_menu'       => false,       // Boolean. Whether the product has an admin menu: $product['info']['has_admin_menu'].
			'hidden'               => false,       // Boolean. Whether the product should be hidden from the UI: $product['info']['hidden'].
			'free'                 => false,       // Boolean. Whether the product is free: $product['info']['free'].
			'third_party'          => false,       // Boolean. Whether is not a GravityKit product: $product['info']['third_party'].
			'server_version'       => '',          // String. Latest available product version: $product['licensing']['version'].
			'coming_soon'          => false,       // Boolean. Whether the product is coming soon: $product['info']['coming_soon'].
			'name'                 => '',          // String. Product name: $product['info']['title'].
			'excerpt'              => '',          // String. Product excerpt: $product['info']['excerpt'].
			'buy_link'             => '',          // String. Product buy link: $product['info']['buy_url'].
			'link'                 => '',          // String. Product information link: $product['info']['link'].
			'download_link'        => '',          // String. Product download link (for free product): $product['info']['download_link'].
			'icon'                 => '',          // String. Product icon: $product['info']['icon'].
			'icons'                => [            // Array. Product icons (JSON-encoded) that are displayed in the Plugins page when showing the changelog: $product['readme']['icons'].
				'1x' => '',
				'2x' => '',
			],
			'banners'              => [            // Array. Product banners (JSON-encoded) that are displayed in the Plugins page when showing the changelog: $product['readme']['banners'].
				'low'  => '',
				'high' => '',
			],
			'sections'             => [            // Array. Product changelog and description (JSON-encoded) hat are displayed in the Plugins page when showing the changelog: $product['readme']['sections'].
				'description' => '',
				'changelog'   => '',
			],
			'modified_date'        => '',          // String. Product modified date: $product['info']['modified_date'].
			'docs'                 => '',          // String. Product docs link: $product['info']['docs_url'].
			'dependencies'         => [            // Array. Product dependencies: $product['dependencies'].
				[
					'0.0.1' => [
						'system' => [],            // array{'PHP': array{'name': string, 'version': string}, 'WordPress': array{'name': string, 'version': string}}.
						'plugin' => [],            // array{'text_domain': array{'name': string, 'text_domain': string, 'author': string, 'version': string}}.
					],
				],
			],
			// Custom properties.
			'licenses'             => [],          // Array. License keys associated with the product.
			'active'               => false,       // Boolean. Whether the product is active.
			'installed'            => false,       // Boolean. Whether the product is installed.
			'installed_version'    => '',          // String. Installed product version.
			'update_available'     => false,       // Boolean. Whether an update is available for the product.
			'path'                 => '',          // String. Product path.
			'plugin_file'          => '',          // String. Product plugin file.
			'network_activated'    => false,       // Boolean. Whether the product is network activated.
			'settings'             => '',          // String. Product settings URL.
			'has_git_folder'       => false,       // Boolean. Whether the product is installed from a Git repo.
			'checked_dependencies' => [],          // Array. Version-specific product dependencies check results. See ProductManager::get_products_data() for structure.
			'required_by'          => [],          // Array. Products that depend on this product. See ProductDependencyChecker::is_a_dependency_of_any_product() for structure.
			'history'              => [],          // Array. Product history. See ProductHistoryTracker class for structure.
		]; // phpcs:enable Squiz.PHP.CommentedOutCode.Found
	}

	/**
	 * Normalizes product data by merging it with the product schema.
	 *
	 * @since 1.2.0
	 *
	 * @param array $product Product data.
	 *
	 * @return array
	 */
	public function normalize_product_data( $product ) {
		$schema   = Arr::dot( $this->get_product_schema() );
		$_product = Arr::dot( $product );

		$matched_keys = array_intersect_key( $_product, $schema );

		$normalized_data = array_merge( $schema, $matched_keys );
		$normalized_data = Arr::undot( $normalized_data );

		// Arrays with dynamic elements can't be easily normalized due to the strict comparison of the above method, so they have to be set manually.
		$normalized_data['dependencies']         = $product['dependencies'] ?? $this->get_product_schema()['dependencies'];
		$normalized_data['checked_dependencies'] = $product['checked_dependencies'] ?? $this->get_product_schema()['checked_dependencies'];
		$normalized_data['required_by']          = $product['required_by'] ?? $this->get_product_schema()['required_by'];
		$normalized_data['licenses']             = $product['licenses'] ?? $this->get_product_schema()['licenses'];

		// Combine current and legacy text domains to match products that may have changed their text domain.
		$normalized_data['text_domains'] = array_values(
			array_unique(
				array_filter(
					array_merge(
						[ $normalized_data['text_domain'] ],
						array_filter( explode( '|', $normalized_data['text_domain_legacy'] ?? '' ) )
					)
				)
			)
		);

		return $normalized_data;
	}

	/**
	 * Removes older products data from the database. This method is called when activating a product.
	 *
	 * @interal TODO: Move this to a separate class that will handle cleanup tasks.
	 *
	 * @since 1.2.11
	 *
	 * @return void
	 */
	public function cleanup_products_data() {
		global $wpdb;

		$products_data_cache_key       = self::PRODUCTS_DATA_CACHE_ID;
		$older_products_data_cache_key = substr( $products_data_cache_key, 0, strrpos( $products_data_cache_key, '/' ) ?: strlen( $products_data_cache_key ) );

		$wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name != %s",
                $older_products_data_cache_key . '%',
                $products_data_cache_key
            )
        );
	}
}
