<?php
/**
 * Options class to handle all options management.
 *
 * @since   2.0.0
 * @license GPL-2.0-or-later
 * @package Style Manager
 */

declare ( strict_types=1 );

namespace Pixelgrade\StyleManager\Provider;

use Carbon_Fields\Field\Field;
use Pixelgrade\StyleManager\Utils\ArrayHelpers;
use Pixelgrade\StyleManager\Vendor\Cedaro\WP\Plugin\AbstractHookProvider;

/**
 * Class Options to handle all options management (including their configuration).
 * WordPress does all the heavy work for caching get_option() data,
 * so we don't have to do that. But we want to minimize cyclomatic complexity
 * of calling a bunch of WP functions, thus we will cache them in a class as well.
 *
 * @since 2.0.0
 */
class Options extends AbstractHookProvider {

	const MINIMAL_DETAILS_CACHE_KEY = 'pixelgrade_style_manager_options_minimal_details';
	const EXTRA_DETAILS_CACHE_KEY = 'pixelgrade_style_manager_options_extra_details';
	const DETAILS_CACHE_TIMESTAMP_KEY = 'pixelgrade_style_manager_options_details_timestamp';
	const CUSTOMIZER_CONFIG_CACHE_KEY = 'pixelgrade_style_manager_customizer_config';
	const CUSTOMIZER_CONFIG_CACHE_TIMESTAMP_KEY = 'pixelgrade_style_manager_customizer_config_timestamp';
	const CUSTOMIZER_OPT_NAME_CACHE_KEY = 'pixelgrade_style_manager_customizer_opt_name';
	const CUSTOMIZER_OPT_NAME_CACHE_TIMESTAMP_KEY = 'pixelgrade_style_manager_customizer_opt_name_timestamp';

	/**
	 * The cached options with just the minimal details.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	protected array $minimal_details = [];

	/**
	 * The cached full options details.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	protected array $details = [];

	/**
	 * The current option name as defined by the theme.
	 *
	 * @since 2.0.0
	 *
	 * @var string
	 */
	protected string $opt_name = '';

	/**
	 * The cached Customizer config.
	 *
	 * @since 2.0.0
	 *
	 * @var array
	 */
	private array $customizer_config = [];

	/**
	 * Plugin settings.
	 *
	 * @var PluginSettings
	 */
	protected PluginSettings $plugin_settings;

	/**
	 * Create the options provider.
	 *
	 * @since 2.0.0
	 *
	 * @param PluginSettings $plugin_settings Plugin settings.
	 */
	public function __construct(
		PluginSettings $plugin_settings
	) {
		$this->plugin_settings = $plugin_settings;
	}

	/**
	 * Get the configured storage mode for theme-controlled values.
	 *
	 * @since 2.3.0
	 *
	 * @return string
	 */
	public function get_values_store_mod(): string {
		return (string) $this->plugin_settings->get( 'values_store_mod' );
	}

	/**
	 * Register hooks.
	 *
	 * @since 2.0.0
	 */
	public function register_hooks() {

		/*
		 * Handle the force clearing of the caches. We clear in a proactive manner.
		 */
		$this->add_action( 'after_switch_theme', 'invalidate_all_caches', 1 );
		$this->add_action( 'upgrader_process_complete', 'maybe_invalidate_after_upgrade', 1, 2 );

		// Whenever we update data from the Customizer, we will invalidate the options details (that include the value).
		// Customize save (publish) used the same changeset save logic, so this filter is fired then also.
		$this->add_filter( 'customize_changeset_save_data', 'filter_invalidate_details_cache', 50, 1 );

		// Publishing a changeset that touches keys of the multidimensional options root must
		// explicitly carry every other stored key of that root, or read-time filters would get
		// persisted for settings the user did not change (issue #127).
		$this->add_filter( 'customize_changeset_save_data', 'filter_complete_options_root_changeset_data', 40, 2 );

		// Invalidate caches after Pixelgrade Care import.
		$this->add_action( 'pixcare_sce_import_end', 'invalidate_all_caches', 1 );

		/**
		 * Migrate Customizer controls data between site options and theme_mods on plugin settings change.
		 */
		$this->add_action( 'style_manager/plugin_settings_cfdatastore/before_save', 'maybe_migrate_controls_data', 10, 4 );
		// We need to prevent the deletion (upon save) of the "Store value as" option.
		$this->add_filter( 'carbon_fields_should_delete_field_value_on_save', 'prevent_deletion_on_save', 10, 2 );
	}

	/**
	 * Get an option's value, if there is a value, and return it.
	 * Otherwise, try to get the default parameter or the default from config.
	 *
	 * @since 2.0.0
	 *
	 * @param string     $option_id
	 * @param mixed|null $default        Optional.
	 * @param array|null $option_details Optional.
	 *
	 * @return mixed
	 */
	public function get( string $option_id, $default = null, $option_details = null ) {

		if ( null === $option_details ) {
			// Get the field config.
			$option_details = $this->get_details( $option_id, true );
		}

		// If the development constant STYLE_MANAGER_DEV_FORCE_DEFAULTS has been defined we will not retrieve anything from the database
		// Always go with the default
		if ( defined( 'STYLE_MANAGER_DEV_FORCE_DEFAULTS' )
		     && true === STYLE_MANAGER_DEV_FORCE_DEFAULTS
		     && ! $this->skip_dev_mode_force_defaults( $option_id, $option_details ) ) {

			$value = null;
		} else {

			if ( empty( $option_id ) || empty( $option_details ) || ! is_array( $option_details ) ) {
				$value = null;
			} elseif ( isset( $option_details['value'] ) ) {
				// If we already have the value cached in the option details, we will use that.
				$value = $option_details['value'];
			} else {
				$value = null;

				/*
				 * First determine the setting ID.
				 */
				$setting_id = $this->get_options_key() . '[' . $option_id . ']';
				// If we have been explicitly given a setting ID we will use that.
				if ( ! empty( $option_details['setting_id'] ) ) {
					$setting_id = $option_details['setting_id'];
				}

				/*
				 * Second, try to get the stored value of the setting.
				 */

				// If we have a setting that directly declares it (not deduced like when registering fields in the Customizer)
				// should be saved in the wp_options table, not in theme_mods, we will attempt to fetch it directly, first.
				if ( isset( $option_details['setting_type'] ) && $option_details['setting_type'] === 'option' ) {
					$value = \get_option( $setting_id, null );
				}

				// If we don't have a value, we will grab the setting value from the array of values stored in either
				// a wp_option entry or in the theme_mods.
				// The "save as array" behavior happens even in the case of 'option' setting type if
				// the setting ID is of the form 'rosa_option[some_key]' (aka a multidimensional setting ID).
				if ( null === $value ) {
					if ( $this->plugin_settings->get( 'values_store_mod' ) === 'option' ) {
						// Get the value stored in a option.
						$value = $this->get_wpoptions_value( $option_id, $setting_id );
					} else {
						// Get the value stored in theme_mods.
						$value = $this->get_thememod_value( $option_id, $setting_id );
					}
				}
			}
		}

		// If we have a non-null value, return it.
		if ( $value !== null ) {
			return $value;
		}

		// If we have a non-null default, return it.
		if ( $default !== null ) {
			return $default;
		}

		// Finally, attempt to use the default value set in the config, if available.
		if ( ! empty( $option_details ) && is_array( $option_details ) && isset( $option_details['default'] ) ) {
			return $option_details['default'];
		}

		return null;
	}

	/**
	 * Determine if a certain option exists.
	 *
	 * @since 2.0.0
	 *
	 * @param string $key The option key.
	 *
	 * @return bool
	 */
	public function has_option( string $key ): bool {
		$options_details = $this->get_details_all( true );
		if ( isset( $options_details[ $key ] ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get the Style Manager configuration (and value, hence "details") of a certain option.
	 *
	 * @since 2.0.0
	 *
	 * @param string $option_id
	 * @param bool   $minimal_details Optional. Whether to return only the minimum amount of details (mainly what is needed on the frontend).
	 *                                The advantage is that these details are cached, thus skipping the customizer_config!
	 * @param bool   $skip_cache      Optional.
	 *
	 * @return array|false The option config or false on failure.
	 */
	public function get_details( string $option_id, bool $minimal_details = false, bool $skip_cache = false ) {
		if ( empty( $option_id ) ) {
			return false;
		}

		$options_details = $this->get_details_all( $minimal_details, $skip_cache );
		if ( ! empty( $options_details ) && is_array( $options_details ) && isset( $options_details[ $option_id ] ) ) {
			return $options_details[ $option_id ];
		}

		return false;
	}

	/**
	 * Get the value of a setting ID saved in a wp_options array entry.
	 *
	 * @since 2.0.0
	 *
	 * @param string                 $option_id  This is only the option ID, that may differ from setting ID ( like in `body_font` vs `rosa_opt[body_font]`)
	 * @param string                 $setting_id We will use this to get the Customizer value, when in that context.
	 *
	 * @return mixed|null
	 * @global \WP_Customize_Manager $wp_customize
	 *
	 */
	protected function get_wpoptions_value( string $option_id, string $setting_id ) {
		global $wp_customize;

		if ( empty( $option_id ) || empty( $setting_id ) ) {
			return null;
		}

		if ( ! empty( $wp_customize ) && method_exists( $wp_customize, 'get_setting' ) ) {
			$setting = $wp_customize->get_setting( $setting_id );
			if ( ! empty( $setting ) ) {
				return $setting->value();
			}
		}

		$values = \get_option( $this->get_options_key() );

		if ( ! empty( $values ) && is_array( $values ) && isset( $values[ $option_id ] ) ) {
			return $values[ $option_id ];
		}

		return null;
	}

	/**
	 * Get the value of a certain setting ID saved in the theme mod array.
	 *
	 * @since 2.0.0
	 *
	 * @param string                 $option_id  This is only the option ID, that may differ from setting ID ( like in `body_font` vs `rosa_opt[body_font]`)
	 * @param string                 $setting_id We will use this to get the Customizer value, when in that context.
	 *
	 * @return mixed|null
	 * @global \WP_Customize_Manager $wp_customize
	 *
	 */
	protected function get_thememod_value( string $option_id, string $setting_id ) {
		global $wp_customize;

		if ( empty( $option_id ) || empty( $setting_id ) ) {
			return null;
		}

		if ( ! empty( $wp_customize ) && method_exists( $wp_customize, 'get_setting' ) ) {
			$setting = $wp_customize->get_setting( $setting_id );
			if ( ! empty( $setting ) ) {
				$undefined  = new \stdClass();
				$post_value = $setting->post_value( $undefined );

				// Trust explicit preview payloads, but let the aggregated root theme_mod
				// remain the source of truth on initial loads so root-level filters still apply.
				if ( $undefined !== $post_value && ! is_wp_error( $post_value ) ) {
					return $post_value;
				}
			} elseif ( $wp_customize->is_preview() ) {
				// If the setting is not registered (like in asking for the value before wp_loaded), we will read directly from the posted values via the changeset.
				// Not really the best way, but ok.
				$post_values = $wp_customize->unsanitized_post_values();
				if ( array_key_exists( $setting_id, $post_values ) ) {
					$value = $post_values[ $setting_id ];
					// Skip validation and sanitization since it is too early.
					if ( ! is_null( $value ) && ! is_wp_error( $value ) ) {
						return $value;
					}
				}
			}
		}

		$values = \get_theme_mod( $this->get_options_key() );

		if ( ! empty( $values ) && is_array( $values ) && isset( $values[ $option_id ] ) ) {
			return $values[ $option_id ];
		}

		return null;
	}

	/**
	 * Determine if we should NOT enforce the STYLE_MANAGER_DEV_FORCE_DEFAULTS behavior on a certain option.
	 *
	 * @since 2.0.0
	 *
	 * @param string     $option_id
	 * @param array|null $option_config Optional.
	 *
	 * @return bool
	 */
	public function skip_dev_mode_force_defaults( string $option_id, $option_config = null ): bool {
		// Preprocess the $option_id.
		if ( false !== strpos( $option_id, '::' ) ) {
			$option_id = substr( $option_id, strpos( $option_id, '::' ) + 2 );
		}
		if ( false !== strpos( $option_id, '[' ) ) {
			$option_id = explode( '[', $option_id );
			$option_id = rtrim( $option_id[1], ']' );
		}

		if ( null === $option_config ) {
			$option_config = $this->get_details( $option_id, true );
		}
		if ( empty( $option_config ) || ! is_array( $option_config ) ) {
			return false;
		}

		// We will skip certain field types that generally don't have a default value.
		if ( ! empty( $option_config['type'] ) ) {
			switch ( $option_config['type'] ) {
				case 'cropped_image':
				case 'cropped_media':
				case 'image':
				case 'media':
				case 'custom_background':
				case 'upload':
					return true;
					break;
				default:
					break;
			}
		}

		return false;
	}

	/**
	 * Invalidate all caches.
	 *
	 * @since 2.0.0
	 */
	public function invalidate_all_caches() {
		$this->invalidate_customizer_config_cache();
		$this->invalidate_details_cache();
		$this->invalidate_customizer_opt_name_cache();
		$this->invalidate_details_cache();

		\do_action( 'style_manager/invalidate_all_caches' );
	}

	/**
	 * Invalidate caches after an upgrade only when the upgrade is relevant to Style Manager.
	 *
	 * Previously hooked unconditionally to upgrader_process_complete, which fired on every
	 * plugin auto-update, translation update, and core update — busting the 24h Customizer
	 * config cache on each one. See pixelgrade/style-manager#84.
	 *
	 * Now invalidates only when:
	 *   - a theme is installed/updated (theme config changes can affect SM)
	 *   - Style Manager itself is in the plugin package list
	 *
	 * Translation and core updates are no-ops.
	 *
	 * @param \WP_Upgrader $upgrader
	 * @param array        $hook_extra
	 */
	public function maybe_invalidate_after_upgrade( $upgrader, $hook_extra ) {
		if ( empty( $hook_extra['type'] ) ) {
			return;
		}

		if ( 'theme' === $hook_extra['type'] ) {
			$this->invalidate_all_caches();
			return;
		}

		if ( 'plugin' === $hook_extra['type'] && ! empty( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
			foreach ( $hook_extra['plugins'] as $plugin_file ) {
				if ( false !== stripos( (string) $plugin_file, 'style-manager' ) ) {
					$this->invalidate_all_caches();
					return;
				}
			}
		}
	}

	/**
	 * This will clear any instance properties that are used as local cache during a request to avoid
	 * fetching the data from DB on each method call.
	 *
	 * This may be called during a request when something happens that (potentially) invalidates our data mid-request.
	 *
	 * @since 2.0.0
	 */
	protected function clear_locally_cached_data() {
		$this->opt_name = '';

		$this->customizer_config = [];

		$this->minimal_details = [];
		$this->details         = [];
	}

	/**
	 * Get the key under which all options are saved.
	 *
	 * @since 2.0.0
	 *
	 * @param bool $skip_cache Optional. Whether to skip the options cache and regenerate.
	 *                         Defaults to using the cache.
	 *
	 * @return string
	 */
	public function get_options_key( bool $skip_cache = false ): string {
		if ( ! empty( $this->opt_name ) ) {
			return $this->opt_name;
		}

		if ( $this->should_force_skip_cache() ) {
			$skip_cache = true;
		}

		// First try and get the cached data
		$data             = \get_option( self::CUSTOMIZER_OPT_NAME_CACHE_KEY );
		$expire_timestamp = false;

		// An empty option name cannot address any theme settings. If a
		// previous request cached an empty value before the Customizer config
		// was ready, treat it as a miss so the next request can recover.
		if ( '' === $data ) {
			$data = false;
		}

		// Only try to get the expire timestamp if we really need to.
		if ( true !== $skip_cache && false !== $data ) {
			// Get the cache data expiration timestamp.
			$expire_timestamp = \get_option( self::CUSTOMIZER_OPT_NAME_CACHE_TIMESTAMP_KEY );
		}

		// The data isn't set, is expired or we were instructed to skip the cache; we need to regenerate the config.
		if ( true === $skip_cache || false === $data || false === $expire_timestamp || $expire_timestamp < time() ) {

			$data = (string) $this->get_customizer_config( 'opt-name' );

			if ( true !== $skip_cache && '' !== $data ) {
				// Cache the data in an option for 24 hours, but only if we are not supposed to skip the cache entirely.
				// Not autoloaded — this cache is consumed only in admin/Customizer paths. See pixelgrade/style-manager#86.
				\update_option( self::CUSTOMIZER_OPT_NAME_CACHE_KEY, $data, false );
				\update_option( self::CUSTOMIZER_OPT_NAME_CACHE_TIMESTAMP_KEY, time() + 24 * HOUR_IN_SECONDS, false );
			}
		}

		$this->opt_name = $data;

		return $data;
	}

	/**
	 * Invalidate the Customizer options name (opt-name) cache.
	 *
	 * @since 2.0.0
	 */
	public function invalidate_customizer_opt_name_cache() {
		update_option( self::CUSTOMIZER_OPT_NAME_CACHE_TIMESTAMP_KEY, time() - 24 * HOUR_IN_SECONDS, false );

		$this->clear_locally_cached_data();
	}

	/**
	 * Wrapper to invalidate_customizer_opt_name_cache() for hooking into filters.
	 *
	 * @since 2.0.0
	 *
	 * @param mixed $value The value is just passed along, not modified.
	 *
	 * @return mixed
	 */
	public function filter_invalidate_customizer_opt_name_cache( $value ) {
		$this->invalidate_customizer_opt_name_cache();

		return $value;
	}

	/**
	 * Get all options' details.
	 *
	 * @since 2.0.0
	 *
	 * @param bool $only_minimal_details Optional. Whether to return only the minimal details.
	 *                                   Defaults to returning all details.
	 * @param bool $skip_cache           Optional. Whether to skip the options cache and regenerate.
	 *                                   Defaults to using the cache.
	 *
	 * @return array
	 */
	public function get_details_all( bool $only_minimal_details = false, bool $skip_cache = false ): array {

		// If we already have the data, do as little as possible.
		if ( true === $only_minimal_details && ! empty( $this->minimal_details ) ) {
			return $this->minimal_details;
		}
		if ( ! empty( $this->details ) ) {
			return $this->details;
		}

		if ( $this->should_force_skip_cache() ) {
			$skip_cache = true;
		}

		// We will first look for cached data.
		$data = \get_option( self::MINIMAL_DETAILS_CACHE_KEY );

		// Guard against a pathological persisted state: a previous
		// regeneration can save an empty array (e.g., if it ran before all
		// `style_manager/filter_fields` listeners had attached) with a
		// fresh 24h expiration. Without this check an empty `[]` survives
		// as "valid" data and the frontend gets no dynamic CSS for a full
		// day. Treat an empty array identically to a missing cache so
		// regeneration will run below.
		if ( is_array( $data ) && empty( $data ) ) {
			$data = false;
		}

		if ( false !== $data && false === $only_minimal_details ) {
			$this->minimal_details = $data;

			$extra_details_data = \get_option( self::EXTRA_DETAILS_CACHE_KEY );
			if ( is_array( $extra_details_data ) ) {
				$data = $this->details = ArrayHelpers::array_merge_recursive_distinct( $data, $extra_details_data );
			} else {
				// Something is wrong with the extra details and we need to regenerate.
				$this->invalidate_details_cache();
			}
		}

		// For performance reasons, we will use the cached data (even if stale)
		// when a user is not logged in or a user without administrative capabilities is logged in.
		if ( false !== $data && false === $skip_cache && ! current_user_can( 'manage_options' ) ) {
			return $data;
		}

		$expire_timestamp = false;

		// Only try to get the expire timestamp if we really need to.
		if ( true !== $skip_cache && false !== $data ) {
			// Get the cached data expiration timestamp.
			$expire_timestamp = \get_option( self::DETAILS_CACHE_TIMESTAMP_KEY );
		}

		// The data isn't set, is expired or we were instructed to skip the cache; we need to regenerate the config.
		if ( true === $skip_cache || false === $data || false === $expire_timestamp || $expire_timestamp < time() ) {
			$options_minimal_details = [];
			$options_extra_details   = [];

			$minimal_detail_keys = [
				'type',
				'setting_type',
				'setting_id',
				'default',
				'css',
				'output',
				'value',
				'selector',
				'callback',
				'active_callback',
			];

			$customizer_config = $this->get_customizer_config();

			if ( isset ( $customizer_config['panels'] ) ) {
				foreach ( $customizer_config['panels'] as $pane_id => $panel_settings ) {
					if ( isset( $panel_settings['sections'] ) ) {
						foreach ( $panel_settings['sections'] as $section_id => $section_settings ) {
							if ( isset( $section_settings['options'] ) ) {
								foreach ( $section_settings['options'] as $option_id => $option_config ) {
									if ( is_array( $option_config ) ) {
										foreach ( $option_config as $key => $value ) {
											if ( in_array( $key, $minimal_detail_keys ) ) {
												$options_minimal_details[ $option_id ][ $key ] = $value;
											} else {
												$options_extra_details[ $option_id ][ $key ] = $value;
											}
										}

										$options_minimal_details[ $option_id ]['value'] = $this->get( $option_id, null, $option_config );
									}
								}
							}
						}
					}
				}
			}

			if ( isset ( $customizer_config['sections'] ) ) {
				foreach ( $customizer_config['sections'] as $section_id => $section_settings ) {
					if ( isset( $section_settings['options'] ) ) {
						foreach ( $section_settings['options'] as $option_id => $option_config ) {
							if ( is_array( $option_config ) ) {
								foreach ( $option_config as $key => $value ) {
									if ( in_array( $key, $minimal_detail_keys ) ) {
										$options_minimal_details[ $option_id ][ $key ] = $value;
									} else {
										$options_extra_details[ $option_id ][ $key ] = $value;
									}
								}

								$options_minimal_details[ $option_id ]['value'] = $this->get( $option_id, null, $option_config );
							}
						}
					}
				}
			}

			// Only persist the cache when regeneration actually produced
			// options. An empty result is usually a symptom of something
			// upstream being in a half-built state (e.g., `filter_fields`
			// listeners haven't all attached yet) and persisting it with
			// a 24h expiration would make every subsequent request serve
			// empty dynamic CSS. Letting the next request regenerate
			// fresh is safer than caching the empty state.
			if ( true !== $skip_cache && ! empty( $options_minimal_details ) ) {
				// Cache the data for 24 hours, but only if we are not supposed to skip the cache entirely.
				// None of these are autoloaded — admin/Customizer-only consumers. See pixelgrade/style-manager#86.
				\update_option( self::MINIMAL_DETAILS_CACHE_KEY, $options_minimal_details, false );
				\update_option( self::EXTRA_DETAILS_CACHE_KEY, $options_extra_details, false );
				\update_option( self::DETAILS_CACHE_TIMESTAMP_KEY, time() + 24 * HOUR_IN_SECONDS, false );
			}

			$data          = $this->minimal_details = $options_minimal_details;
			$this->details = ArrayHelpers::array_merge_recursive_distinct( $options_minimal_details, $options_extra_details );
			if ( false === $only_minimal_details ) {
				$data = $this->details;
			}
		}

		return $data;
	}

	/**
	 * @return bool
	 */
	private function should_force_skip_cache(): bool {
		// If our development constant is defined and true, we will always skip the cache, except for AJAX calls.
		// Other, more specific cases may impose skipping the cache also on AJAX calls.
		if ( ! wp_doing_ajax()
		     && defined( 'STYLE_MANAGER_ALWAYS_GENERATE_CUSTOMIZER_CONFIG' )
		     && true === STYLE_MANAGER_ALWAYS_GENERATE_CUSTOMIZER_CONFIG ) {
			return true;
		}

		// If we are in the Customizer and the request has a $_POST['customized'] parameter, we will skip the cache
		// since this means that the preview is being reloaded with temporary settings values.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- read-only cache decision during a Customizer preview; WordPress handles preview authentication.
		if ( ! empty( $_POST['customized'] ) ) {
			return true;
		}

		// If we are currently previewing a theme without being actually active, we should not use cached data.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache decision during theme preview; WordPress handles preview authentication.
		if ( ! empty( $_REQUEST['theme'] ) || ! empty( $_REQUEST['customize_theme'] ) ) {
			return true;
		}

		/** @var \WP_Customize_Manager $wp_customize */
		global $wp_customize;
		if ( ! empty( $wp_customize )
		     && method_exists( $wp_customize, 'is_theme_active' )
		     && ! $wp_customize->is_theme_active() ) {

			return true;
		}

		return false;
	}

	/**
	 * Invalidate the options details cache.
	 *
	 * @since 2.0.0
	 */
	protected function invalidate_details_cache() {
		\delete_option( self::MINIMAL_DETAILS_CACHE_KEY );
		\delete_option( self::EXTRA_DETAILS_CACHE_KEY );
		\update_option( self::DETAILS_CACHE_TIMESTAMP_KEY, time() - 24 * HOUR_IN_SECONDS, false );

		$this->clear_locally_cached_data();
	}

	/**
	 * Wrapper to invalidate_options_details_cache() for hooking into filters.
	 *
	 * @since 2.0.0
	 *
	 * @param mixed $value The value is just passed along, not modified.
	 *
	 * @return mixed
	 */
	protected function filter_invalidate_details_cache( $value ) {
		$this->invalidate_details_cache();

		return $value;
	}

	/**
	 * Ensure a publishing changeset explicitly carries every stored key of the
	 * Style Manager-managed multidimensional options root.
	 *
	 * Core's aggregated-multidimensional mechanics seed the shared root value
	 * through read filters (`theme_mod_*` / `option_*`) and persist the whole
	 * root when importing any single key of it. Any read-time filter on the
	 * options root would therefore get silently written to the database for
	 * every sibling setting that was not part of the changeset. By completing
	 * the changeset with the raw stored values of the missing siblings, each
	 * key is imported explicitly and filtered reads can never be persisted
	 * for settings the user did not change. See issue #127.
	 *
	 * @since 2.3.0
	 *
	 * @param mixed $data    Changeset data, keyed by (possibly stylesheet-prefixed) setting ID.
	 * @param mixed $context Filter context from save_changeset_post(); `status` is what we care about.
	 *
	 * @return mixed
	 */
	protected function filter_complete_options_root_changeset_data( $data, $context = [] ) {
		if ( ! is_array( $data ) || empty( $data ) ) {
			return $data;
		}

		$status = is_array( $context ) && isset( $context['status'] ) ? (string) $context['status'] : '';
		if ( ! in_array( $status, [ 'publish', 'future' ], true ) ) {
			return $data;
		}

		$options_key = $this->get_options_key();
		if ( '' === $options_key ) {
			return $data;
		}

		$pattern = '/^(?:(?P<stylesheet>.+?)::)?' . preg_quote( $options_key, '/' ) . '\[(?P<sub_key>[^\]]+)\]$/';

		// stylesheet ('' for option-storage roots) => [ sub_key => true ].
		$present_sub_keys = [];
		$root_types       = [];
		foreach ( $data as $raw_setting_id => $params ) {
			if ( ! is_array( $params ) || ! array_key_exists( 'value', $params ) ) {
				continue;
			}

			if ( ! preg_match( $pattern, (string) $raw_setting_id, $matches ) ) {
				continue;
			}

			$stylesheet = isset( $matches['stylesheet'] ) ? (string) $matches['stylesheet'] : '';

			$present_sub_keys[ $stylesheet ][ $matches['sub_key'] ] = true;
			if ( ! isset( $root_types[ $stylesheet ] ) && ! empty( $params['type'] ) ) {
				$root_types[ $stylesheet ] = (string) $params['type'];
			}
		}

		if ( empty( $present_sub_keys ) ) {
			return $data;
		}

		foreach ( $present_sub_keys as $stylesheet => $sub_keys ) {
			$stored_root = $this->get_stored_options_root( $options_key, (string) $stylesheet );
			if ( empty( $stored_root ) ) {
				continue;
			}

			$type = $root_types[ $stylesheet ] ?? ( '' === $stylesheet ? 'option' : 'theme_mod' );

			foreach ( $stored_root as $sub_key => $stored_value ) {
				if ( ! is_string( $sub_key ) || isset( $sub_keys[ $sub_key ] ) ) {
					continue;
				}

				$raw_setting_id = ( '' === $stylesheet ? '' : $stylesheet . '::' ) . $options_key . '[' . $sub_key . ']';
				if ( isset( $data[ $raw_setting_id ] ) ) {
					continue;
				}

				$data[ $raw_setting_id ] = [
					'value'   => $stored_value,
					'type'    => $type,
					'user_id' => \get_current_user_id(),
				];
			}
		}

		return $data;
	}

	/**
	 * The raw stored options-root array, read straight from the database row,
	 * bypassing `option_*` / `theme_mod_*` read filters and object caches.
	 *
	 * @since 2.3.0
	 *
	 * @param string $options_key The multidimensional root key (e.g. `mies-lt_options`).
	 * @param string $stylesheet  The theme stylesheet for theme_mod storage, or '' for option storage.
	 *
	 * @return array sub_key => stored value
	 */
	protected function get_stored_options_root( string $options_key, string $stylesheet ): array {
		global $wpdb;

		$option_name = '' === $stylesheet ? $options_key : 'theme_mods_' . $stylesheet;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This intentionally bypasses option/theme_mod read filters and object caches to recover the raw stored root.
		$row = $wpdb->get_var(
			$wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", $option_name )
		);

		if ( null === $row ) {
			return [];
		}

		$value = \maybe_unserialize( $row );

		if ( '' !== $stylesheet ) {
			$value = is_array( $value ) && isset( $value[ $options_key ] ) ? $value[ $options_key ] : [];
		}

		return is_array( $value ) ? $value : [];
	}

	/**
	 * Get the entire Customizer fields config or a certain entry key.
	 *
	 * @since 2.0.0
	 *
	 * @param bool|string $key
	 *
	 * @return array|mixed|null
	 */
	public function get_customizer_config( $key = false ) {
		$customizer_config = $this->load_customizer_config();

		if ( false !== $key ) {
			if ( is_array( $customizer_config ) && isset( $customizer_config[ $key ] ) ) {
				return $customizer_config[ $key ];
			}

			return null;
		}

		return $customizer_config;
	}

	/**
	 * Load and set the customizer configuration.
	 *
	 * @since 2.0.0
	 *
	 * @param bool $skip_cache Optional. Whether to use the cached config or generate a new one.
	 *
	 * @return array
	 */
	protected function load_customizer_config( bool $skip_cache = false ): array {
		if ( ! empty( $this->customizer_config ) ) {
			return $this->customizer_config;
		}

		if ( $this->should_force_skip_cache() ) {
			$skip_cache = true;
		}

		// First try and get the cached data
		$data = \get_option( self::CUSTOMIZER_CONFIG_CACHE_KEY );

		// For performance reasons, we will use the cached data (even if stale)
		// when a user is not logged in or a user without administrative capabilities is logged in.
		if ( false !== $data && false === $skip_cache && ! current_user_can( \Pixelgrade\StyleManager\Capabilities::MANAGE_OPTIONS ) ) {
			$this->customizer_config = $data;

			return $data;
		}

		$expire_timestamp = false;

		// Only try to get the expire timestamp if we really need to.
		if ( true !== $skip_cache && false !== $data ) {
			// Get the cache data expiration timestamp.
			$expire_timestamp = \get_option( self::CUSTOMIZER_CONFIG_CACHE_TIMESTAMP_KEY );
		}

		// The data isn't set, is expired, or we were instructed to skip the cache; we need to regenerate the config.
		if ( true === $skip_cache || false === $data || false === $expire_timestamp || $expire_timestamp < time() ) {
			// Allow themes or other plugins to filter the config.
			$data = \apply_filters( 'style_manager/filter_fields', [] );
			// This is just for backwards compatibility.
			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- legacy Customify-era hook kept for back-compat with existing integrations.
			$data = apply_filters( 'customify_filter_fields', $data );
			// Make sure that we have an array.
			if ( ! is_array( $data ) ) {
				\_doing_it_wrong( __METHOD__, esc_html__( 'The Style Manager fields configuration should be an array. Please check the filters that are hooked into \'style_manager/filter_fields\'.', 'style-manager' ), null );

				$data = [];
			}
			// We apply a second filter for those that wish to work with the final config and not rely on a huge priority number.
			$data = apply_filters( 'style_manager/final_config', $data );
			// This is just for backwards compatibility.
			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- legacy Customify-era hook kept for back-compat with existing integrations.
			$data = apply_filters( 'customify_final_config', $data );
			// Make sure that we have an array.
			if ( ! is_array( $data ) ) {
				\_doing_it_wrong( __METHOD__, esc_html__( 'The Style Manager fields configuration should be an array. Please check the filters that are hooked into \'style_manager/final_config\'.', 'style-manager' ), null );

				$data = [];
			}

			if ( true !== $skip_cache ) {
				// Cache the data in an option for 24 hours, but only if we are not supposed to skip the cache entirely.
				// Neither autoloaded — only consumed in admin/Customizer paths. See pixelgrade/style-manager#86.
				\update_option( self::CUSTOMIZER_CONFIG_CACHE_KEY, $data, false );
				\update_option( self::CUSTOMIZER_CONFIG_CACHE_TIMESTAMP_KEY, time() + 24 * HOUR_IN_SECONDS, false );
			}
		}

		/** @noinspection PhpFieldAssignmentTypeMismatchInspection */
		$this->customizer_config = $data;

		return $data;
	}

	/**
	 * Invalidate the Customizer fields config cache.
	 *
	 * @since 2.0.0
	 */
	protected function invalidate_customizer_config_cache() {
		\update_option( self::CUSTOMIZER_CONFIG_CACHE_TIMESTAMP_KEY, time() - 24 * HOUR_IN_SECONDS, false );

		$this->clear_locally_cached_data();
	}

	protected function maybe_migrate_controls_data( $key, $value, $new_all_values, $old_all_values ) {
		// Only migrate on `values_store_mod` change.
		if ( $key !== 'values_store_mod' ) {
			return;
		}
		if ( ! isset( $old_all_values['values_store_mod'] ) || ! isset( $new_all_values['values_store_mod'] ) ) {
			return;
		}
		if ( $old_all_values['values_store_mod'] === $new_all_values['values_store_mod'] ) {
			return;
		}

		$old_values_store = $old_all_values['values_store_mod'];
		$new_values_store = $new_all_values['values_store_mod'];

		$settings = $this->get_details_all();
		if ( empty( $settings ) ) {
			return;
		}

		$options_values = \get_option( $this->get_options_key() );
		$theme_mods_values = \get_theme_mod( $this->get_options_key() );

		foreach ( $settings as $setting_id => $setting_details ) {
			// Ignore certain purely visual settings.
			if ( ! empty( $setting_details['type'] ) && in_array( $setting_details['type'], [
					'html',
					'button',
				] ) ) {

				continue;
			}

			// Ignore settings that declare their own type of storage.
			if ( isset( $setting_details['setting_type'] ) ) {
				continue;
			}

			// If we have a value saved in the old store, save it in the new one.
			$old_setting_value = null;
			if ( $old_values_store === 'option' && isset( $options_values[ $setting_id ] ) ) {
				$old_setting_value = $options_values[ $setting_id ];
			} else if ( $old_values_store === 'theme_mod' && isset( $theme_mods_values[ $setting_id ] ) ) {
				$old_setting_value = $theme_mods_values[ $setting_id ];
			}

			// No old value to migrate.
			if ( $old_setting_value === null ) {
				continue;
			}

			// Migrate the old value to the new store location.
			if ( $new_values_store === 'option' ) {
				$options_values[ $setting_id ] = $old_setting_value;
			} else {
				$theme_mods_values[ $setting_id ] = $old_setting_value;
			}
		}

		// Finally, update the DB values.
		if ( $new_values_store === 'option' ) {
			update_option( $this->get_options_key(), $options_values );
		} else {
			set_theme_mod( $this->get_options_key(), $theme_mods_values );
		}
	}

	protected function prevent_deletion_on_save( bool $delete, Field $field ) : bool {
		if ( $field->get_base_name() === 'values_store_mod' ) {
			$delete = false;
		}

		return $delete;
	}
}
