<?php
namespace PowerpackElements\Classes;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Declarative registry of every persisted admin setting.
 *
 * This is the single source of truth for how each setting is stored, who may
 * read or write it, and — critically — what happens when a field is *absent*
 * from an incoming payload. The legacy form handlers encoded that last part
 * ad hoc, inconsistently, which is how saving the WooCommerce Builder tab came
 * to blank template options it rendered no fields for.
 *
 * Value shapes here are load-bearing: they are read at render time by widgets
 * across the plugin, so they must not drift. See the notes on each field.
 *
 * @since 3.0.0
 */
final class PP_Settings_Registry {

	/**
	 * Option holding the serialized white label / maps settings array.
	 */
	const SETTINGS_OPTION = 'pp_elementor_settings';

	/**
	 * Sentinel written when a list field has nothing selected.
	 *
	 * An empty array is NOT equivalent: pp_get_enabled_extensions() and friends
	 * treat a missing or non-'disabled' value as "everything enabled".
	 */
	const NONE_SELECTED = 'disabled';

	/**
	 * Cached field map.
	 *
	 * @var array|null
	 */
	private static $fields = null;

	/**
	 * Empty-value strategies.
	 *
	 * THE INVARIANT: a field absent from the payload is never touched, whatever
	 * its strategy. Only values actually submitted are acted on. This is what
	 * makes partial updates safe, and it is what the legacy handlers got wrong —
	 * they inferred "unchecked" from "missing from $_POST", so any option whose
	 * form field had been dropped got blanked on every save.
	 *
	 * These strategies therefore describe how a *submitted but empty* value is
	 * stored:
	 *
	 * store    Store it as-is; an empty string stays an empty string.
	 * delete   Truthy stores 1, falsy deletes the option outright. Readers test
	 *          the truthiness of get_option(), so "off" must be an absent
	 *          option rather than a stored 0.
	 * sentinel An empty list stores the string 'disabled', never [].
	 */
	const ON_EMPTY_STORE    = 'store';
	const ON_EMPTY_DELETE   = 'delete';
	const ON_EMPTY_SENTINEL = 'sentinel';

	/**
	 * Get the full field map.
	 *
	 * @since 3.0.0
	 * @return array Map of field key => descriptor.
	 */
	public static function get_fields() {
		if ( null !== self::$fields ) {
			return self::$fields;
		}

		self::$fields = apply_filters( 'pp_elements_settings_fields', self::define_fields() );

		return self::$fields;
	}

	/**
	 * Field definitions.
	 *
	 * Descriptor keys:
	 *   group     Logical tab the field belongs to.
	 *   store     'option' for a standalone option, 'settings' for a sub-key of
	 *             pp_elementor_settings.
	 *   type      string|url|textarea|on_off|boolean|enum|list
	 *   strategy  See the STRATEGY_* constants.
	 *   cap       Capability required to read and write the field.
	 *   network_override  Passed through to the multisite read/write helpers.
	 *   secret    Value is a credential: masked on read, never blanked by an
	 *             empty string on write.
	 *   choices   Allowed values for 'enum', or a callable returning the allowed
	 *             members for 'list'.
	 *   default   Value returned when nothing is stored.
	 *
	 * @since 3.0.0
	 * @return array
	 */
	private static function define_fields() {
		$fields = [];

		/*
		 * White label. Stored as sub-keys of pp_elementor_settings.
		 *
		 * The hide_* flags are 'on'/'off' STRINGS, not booleans — every consumer
		 * compares against those literals.
		 *
		 * Every key here is STRATEGY_SKIP: the legacy handler rebuilt the whole
		 * array from $_POST on each save, which is why hide_integration_tab (a
		 * key with no form field) was force-reset to 'off' every time.
		 */
		$wl_text = [
			'plugin_name'       => 'sanitize_text_field',
			'plugin_short_name' => 'sanitize_text_field',
			'plugin_author'     => 'sanitize_text_field',
			'admin_label'       => 'sanitize_text_field',
		];

		foreach ( $wl_text as $key => $sanitize ) {
			$fields[ $key ] = [
				'group'    => 'white_label',
				'store'    => 'settings',
				'type'     => 'string',
				'strategy' => self::ON_EMPTY_STORE,
				'cap'      => 'delete_users',
				'sanitize' => $sanitize,
				'default'  => '',
			];
		}

		$fields['plugin_desc'] = [
			'group'    => 'white_label',
			'store'    => 'settings',
			'type'     => 'textarea',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'delete_users',
			'sanitize' => 'esc_textarea',
			'default'  => '',
		];

		/*
		 * The mark shown in place of ours. Stored as an attachment ID when it
		 * comes from the media library, so it survives the site moving domain,
		 * and as a plain URL when it does not — an agency hosting its logo on a
		 * CDN has nothing to pick.
		 *
		 * 'media' is not a type normalize() knows, so it falls through to the
		 * sanitize callback below, which is the whole implementation.
		 */
		$fields['plugin_logo'] = [
			'group'    => 'white_label',
			'store'    => 'settings',
			'type'     => 'media',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'delete_users',
			'sanitize' => [ __CLASS__, 'sanitize_media' ],
			'default'  => '',
		];

		foreach ( [ 'plugin_uri', 'support_link', 'docs_link' ] as $key ) {
			$fields[ $key ] = [
				'group'    => 'white_label',
				'store'    => 'settings',
				'type'     => 'url',
				'strategy' => self::ON_EMPTY_STORE,
				'cap'      => 'delete_users',
				'sanitize' => 'esc_url_raw',
				'default'  => '',
			];
		}

		$hide_flags = [
			'hide_support',
			'hide_widget_help',
			'hide_wl_settings',
			'hide_plugin',
			'hide_logo',
			'hide_license_key_link',
			'hide_demo_links',
			'hide_docs_links',

			/*
			 * Panel visibility. These hide a tab from this screen; they are not a
			 * capability check, and the settings behind a hidden tab keep their
			 * values. White Label has no flag of its own here because
			 * hide_wl_settings already does that job, and undoes itself only on
			 * reactivation.
			 */
			'hide_elements_tab',
			'hide_extensions_tab',
			'hide_integration_tab',
			'hide_header_footer_tab',
			'hide_woo_tab',
			'hide_login_register_tab',
			'hide_advanced_tab',
		];

		foreach ( $hide_flags as $key ) {
			$fields[ $key ] = [
				'group'    => 'white_label',
				'store'    => 'settings',
				'type'     => 'on_off',
				'strategy' => self::ON_EMPTY_STORE,
				'cap'      => 'delete_users',

				'default'  => 'hide_widget_help' === $key ? self::widget_help_default() : 'off',
			];
		}

		/*
		 * Google Maps. Stored inside pp_elementor_settings but surfaced on the
		 * Integration tab, so it carries the integration capability.
		 */
		$fields['google_map_api'] = [
			'group'    => 'integration',
			'store'    => 'settings',
			'type'     => 'string',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'manage_options',
			'sanitize' => 'sanitize_text_field',
			'secret'   => true,
			'default'  => '',
		];

		/*
		 * Typed as an enum so the client renders the language list rather than
		 * a free text box. The stored value is still the same language code it
		 * always was.
		 */
		$fields['google_map_lang'] = [
			'group'    => 'integration',
			'store'    => 'settings',
			'type'     => 'enum',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'manage_options',
			'choices'  => [ __NAMESPACE__ . '\PP_Helper', 'get_google_map_languages' ],
			'default'  => '',
		];

		/*
		 * Integration credentials. All standalone options, all network_override
		 * false, matching the legacy self::update_option( $key, $value, false )
		 * calls. All secret: masked on read, never blanked by an empty string.
		 */
		$credentials = [
			'pp_fb_app_id',
			'pp_fb_app_secret',
			'pp_google_client_id',
			'pp_google_places_api_key',
			'pp_google_calendar_api',
			'pp_yelp_api_key',
			'pp_instagram_access_token',
			'pp_youtube_api_key',
			'pp_recaptcha_site_key',
			'pp_recaptcha_secret_key',
			'pp_recaptcha_v3_site_key',
			'pp_recaptcha_v3_secret_key',
		];

		foreach ( $credentials as $key ) {
			$fields[ $key ] = [
				'group'            => 'integration',
				'store'            => 'option',
				'type'             => 'string',
				'strategy'         => self::ON_EMPTY_STORE,
				'cap'              => 'manage_options',
				'network_override' => false,
				'sanitize'         => 'sanitize_text_field',
				'secret'           => true,
				'default'          => '',
			];
		}

		$fields['pp_enable_csv_upload'] = [
			'group'            => 'integration',
			'store'            => 'option',
			'type'             => 'enum',
			'strategy'         => self::ON_EMPTY_STORE,
			'cap'              => 'manage_options',
			'network_override' => false,
			'choices'          => [ 'enabled', 'disabled' ],
			'default'          => 'disabled',
		];

		/*
		 * Elements. Array of widget names, or the string 'disabled' when nothing
		 * is enabled. Seeded on first admin load by default_disabled_modules().
		 */
		$fields['pp_elementor_modules'] = [
			'group'    => 'modules',
			'store'    => 'option',
			'type'     => 'list',
			'strategy' => self::ON_EMPTY_SENTINEL,
			'cap'      => 'edit_posts',
			'choices'  => 'pp_get_modules',
			'default'  => self::NONE_SELECTED,
		];

		/*
		 * Extensions. Same tri-state shape, but note the asymmetry: an ABSENT
		 * option means "everything enabled" here, while 'disabled' means none.
		 */
		$fields['pp_elementor_extensions'] = [
			'group'    => 'extensions',
			'store'    => 'option',
			'type'     => 'list',
			'strategy' => self::ON_EMPTY_SENTINEL,
			'cap'      => 'edit_posts',
			'choices'  => 'pp_get_extensions',

			// Per-choice documentation links, suppressed by white label like
			// every other docs link on the screen.
			'docs'     => [ __NAMESPACE__ . '\PP_Config', 'get_extension_docs' ],
			'default'  => self::NONE_SELECTED,
		];

		$fields['pp_elementor_taxonomy_thumbnail_enable'] = [
			'group'    => 'extensions',
			'store'    => 'option',
			'type'     => 'enum',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'edit_posts',
			'choices'  => [ 'enabled', 'disabled' ],
			'default'  => self::NONE_SELECTED,
		];

		$fields['pp_elementor_taxonomy_thumbnail_taxonomies'] = [
			'group'    => 'extensions',
			'store'    => 'option',
			'type'     => 'list',
			'strategy' => self::ON_EMPTY_SENTINEL,
			'cap'      => 'edit_posts',
			'choices'  => 'pp_get_thumbnail_taxonomies',
			'default'  => self::NONE_SELECTED,
		];

		/*
		 * Header / Footer. The toggles are presence-based: 1 when on, option
		 * deleted when off.
		 */
		foreach ( [ 'pp_header_footer_template_header', 'pp_header_footer_template_footer' ] as $key ) {
			$fields[ $key ] = [
				'group'    => 'header_footer',
				'store'    => 'option',
				'type'     => 'string',
				'strategy' => self::ON_EMPTY_STORE,
				'cap'      => 'manage_options',
				'sanitize' => 'sanitize_text_field',
				'default'  => '',
			];
		}

		$hf_toggles = [
			'pp_header_footer_fixed_header',
			'pp_header_footer_shrink_header',
			'pp_header_footer_overlay_header',
		];

		foreach ( $hf_toggles as $key ) {
			$fields[ $key ] = [
				'group'    => 'header_footer',
				'store'    => 'option',
				'type'     => 'boolean',
				'strategy' => self::ON_EMPTY_DELETE,
				'cap'      => 'manage_options',
				'default'  => false,
			];
		}

		$fields['pp_header_footer_fixed_header_breakpoints'] = [
			'group'    => 'header_footer',
			'store'    => 'option',
			'type'     => 'string',
			'strategy' => self::ON_EMPTY_DELETE,
			'cap'      => 'manage_options',
			'sanitize' => 'sanitize_text_field',
			'default'  => 'large-medium',
		];

		$fields['pp_header_footer_overlay_header_bg'] = [
			'group'    => 'header_footer',
			'store'    => 'option',
			'type'     => 'enum',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'manage_options',
			'choices'  => [ 'default', 'transparent' ],
			'default'  => 'default',
		];

		/*
		 * WooCommerce Builder.
		 *
		 * The cart / checkout / thankyou / myaccount template options were
		 * dropped from the UI in 2020 and everything that read them has since
		 * been removed, so only the two surviving page templates are registered.
		 */
		$woo_templates = [
			'pp_woo_template_single_product',
			'pp_woo_template_product_archive',
		];

		foreach ( $woo_templates as $key ) {
			$fields[ $key ] = [
				'group'    => 'woo',
				'store'    => 'option',
				'type'     => 'string',
				'strategy' => self::ON_EMPTY_STORE,
				'cap'      => 'manage_options',
				'sanitize' => 'sanitize_text_field',
				'default'  => '',
			];
		}

		foreach ( [ 'pp_woo_builder_enable', 'pp_woo_recently_viewed_tracking' ] as $key ) {
			$fields[ $key ] = [
				'group'    => 'woo',
				'store'    => 'option',
				'type'     => 'boolean',
				'strategy' => self::ON_EMPTY_DELETE,
				'cap'      => 'manage_options',
				'default'  => false,
			];
		}

		/*
		 * Login / Register. Page IDs — stored raw by the legacy handler, cast
		 * to int here.
		 */
		foreach ( [ 'pp_login_page', 'pp_register_page' ] as $key ) {
			$fields[ $key ] = [
				'group'    => 'login_register',
				'store'    => 'option',
				'type'     => 'string',
				'strategy' => self::ON_EMPTY_STORE,
				'cap'      => 'manage_options',
				'sanitize' => 'absint',
				'default'  => '',
			];
		}

		$fields['pp_login_register_redirect'] = [
			'group'    => 'login_register',
			'store'    => 'option',
			'type'     => 'boolean',
			'strategy' => self::ON_EMPTY_DELETE,
			'cap'      => 'manage_options',
			'default'  => false,
		];

		$fields['pp_login_register_logged_in_redirect'] = [
			'group'    => 'login_register',
			'store'    => 'option',
			'type'     => 'enum',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'manage_options',
			'choices'  => [ 'none', 'home', 'dashboard', 'page' ],
			'default'  => 'none',
		];

		$fields['pp_login_register_logged_in_page'] = [
			'group'    => 'login_register',
			'store'    => 'option',
			'type'     => 'string',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'manage_options',
			'sanitize' => 'absint',
			'default'  => '',
		];

		/*
		 * Advanced.
		 *
		 * Read by uninstall.php, which runs with the plugin unloaded and so
		 * keeps its own copy of the key list. Keep the two in step.
		 */
		$fields['pp_delete_data_on_uninstall'] = [
			'group'    => 'advanced',
			'store'    => 'option',
			'type'     => 'boolean',
			'strategy' => self::ON_EMPTY_DELETE,
			'cap'      => 'manage_options',
			'default'  => false,
		];

		/*
		 * License. Status, expiry and user action are written by the updater in
		 * response to the remote store, never by a settings payload, so only the
		 * key itself is registered as writable.
		 */
		$fields['pp_license_key'] = [
			'group'    => 'license',
			'store'    => 'option',
			'type'     => 'string',
			'strategy' => self::ON_EMPTY_STORE,
			'cap'      => 'manage_options',
			'sanitize' => 'sanitize_text_field',
			'secret'   => true,
			'default'  => '',
		];

		return $fields;
	}

	/**
	 * Get a single field descriptor.
	 *
	 * @since 3.0.0
	 * @param string $key Field key.
	 * @return array|null
	 */
	public static function get_field( $key ) {
		$fields = self::get_fields();

		return isset( $fields[ $key ] ) ? $fields[ $key ] : null;
	}

	/**
	 * Get all field keys belonging to a group.
	 *
	 * @since 3.0.0
	 * @param string $group Group name.
	 * @return array
	 */
	public static function get_group_fields( $group ) {
		$fields = [];

		foreach ( self::get_fields() as $key => $field ) {
			if ( $group === $field['group'] ) {
				$fields[ $key ] = $field;
			}
		}

		return $fields;
	}

	/**
	 * Get the list of registered groups.
	 *
	 * @since 3.0.0
	 * @return array
	 */
	public static function get_groups() {
		$groups = [];

		foreach ( self::get_fields() as $field ) {
			$groups[ $field['group'] ] = true;
		}

		return array_keys( $groups );
	}

	/**
	 * Whether the current user may read and write a field.
	 *
	 * @since 3.0.0
	 * @param string $key     Field key.
	 * @param bool   $network Whether the request is in a network admin context.
	 * @return bool
	 */
	public static function current_user_can( $key, $network = false ) {
		$field = self::get_field( $key );

		if ( ! $field ) {
			return false;
		}

		$cap = $network ? 'manage_network_plugins' : $field['cap'];

		return current_user_can( $cap );
	}

	/**
	 * Read a field value.
	 *
	 * @since 3.0.0
	 * @param string $key     Field key.
	 * @param bool   $network Whether the request is in a network admin context.
	 * @return mixed
	 */
	public static function read( $key, $network = false ) {
		$field = self::get_field( $key );

		if ( ! $field ) {
			return null;
		}

		if ( 'settings' === $field['store'] ) {
			$settings = self::get_settings_array();
			$value    = isset( $settings[ $key ] ) ? $settings[ $key ] : $field['default'];
		} else {
			$network_override = isset( $field['network_override'] ) ? $field['network_override'] : true;
			$value            = self::read_option( $key, $network_override, $network );

			if ( false === $value ) {
				$value = $field['default'];
			}
		}

		if ( 'boolean' === $field['type'] ) {
			return (bool) $value;
		}

		return $value;
	}

	/**
	 * Read a field for output, masking credentials.
	 *
	 * Stored secrets never reach the browser in plaintext. The client shows the
	 * mask, omits the field unless the user edits it, and clears a key by
	 * sending an explicit null.
	 *
	 * @since 3.0.0
	 * @param string $key     Field key.
	 * @param bool   $network Whether the request is in a network admin context.
	 * @return mixed
	 */
	public static function read_for_output( $key, $network = false ) {
		$field = self::get_field( $key );
		$value = self::read( $key, $network );

		if ( $field && ! empty( $field['secret'] ) ) {
			return self::mask( $value );
		}

		return $value;
	}

	/**
	 * Mask a credential down to its last four characters.
	 *
	 * @since 3.0.0
	 * @param string $value Raw value.
	 * @return string
	 */
	public static function mask( $value ) {
		$value = (string) $value;

		if ( '' === $value ) {
			return '';
		}

		if ( strlen( $value ) <= 4 ) {
			return str_repeat( '*', strlen( $value ) );
		}

		return str_repeat( '*', 8 ) . substr( $value, -4 );
	}

	/**
	 * Apply a payload of field values.
	 *
	 * Only keys present in the payload are considered; everything else keeps
	 * whatever it already has. Sub-keys of pp_elementor_settings are collected
	 * and written in a single read-modify-write so that one group's save cannot
	 * drop another's values.
	 *
	 * @since 3.0.0
	 * @param array $payload Map of field key => submitted value.
	 * @param array $args    {
	 *     @type bool $network Whether the request is in a network admin context.
	 * }
	 * @return array {
	 *     @type array $written Field keys that were written.
	 *     @type array $skipped Field keys ignored, mapped to a reason.
	 * }
	 */
	public static function apply( array $payload, $args = [] ) {
		$network = ! empty( $args['network'] );
		$written = [];
		$skipped = [];
		$batched = [];

		foreach ( self::get_fields() as $key => $field ) {
			if ( ! array_key_exists( $key, $payload ) ) {
				continue;
			}

			if ( ! self::current_user_can( $key, $network ) ) {
				$skipped[ $key ] = 'forbidden';
				continue;
			}

			$value = $payload[ $key ];

			// Rule 3: a credential is cleared only by an explicit null. An empty
			// string is treated as an untouched masked field and ignored, so a
			// round-tripped placeholder can never wipe a stored key.
			if ( ! empty( $field['secret'] ) ) {
				if ( null === $value ) {
					$value = '';
				} elseif ( '' === $value ) {
					$skipped[ $key ] = 'empty_secret_ignored';
					continue;
				}
			}

			$value = self::normalize( $value, $field, self::read( $key, $network ) );

			if ( 'settings' === $field['store'] ) {
				$batched[ $key ] = $value;
				$written[]       = $key;
				continue;
			}

			$network_override = isset( $field['network_override'] ) ? $field['network_override'] : true;

			if ( self::ON_EMPTY_DELETE === $field['strategy'] && ! self::is_on( $payload[ $key ] ) ) {
				self::delete_option( $key, $network );
			} else {
				self::write_option( $key, $value, $network_override, $network );
			}

			$written[] = $key;
		}

		if ( ! empty( $batched ) ) {
			self::write_settings_array( $batched );
		}

		return [
			'written' => $written,
			'skipped' => $skipped,
		];
	}

	/**
	 * Coerce a submitted value into its stored shape.
	 *
	 * @since 3.0.0
	 * @param mixed $value   Submitted value.
	 * @param array $field   Field descriptor.
	 * @param mixed $stored  Currently stored value, for rules that must not
	 *                       discard what the browser could not have seen.
	 * @return mixed
	 */
	private static function normalize( $value, $field, $stored = null ) {
		switch ( $field['type'] ) {
			case 'on_off':
				return self::is_on( $value ) ? 'on' : 'off';

			case 'boolean':
				return self::is_on( $value ) ? 1 : 0;

			case 'enum':
				$choices = isset( $field['choices'] ) ? $field['choices'] : [];

				if ( is_callable( $choices ) ) {
					// A callable returns a value => label map, so the allowed
					// set is its keys rather than the labels shown to the user.
					$allowed = array_keys( (array) call_user_func( $choices ) );
				} elseif ( is_array( $choices ) ) {
					$allowed = $choices;
				} else {
					$allowed = [];
				}

				// An empty value means "no choice made" and is always allowed;
				// it is what the default is for these fields.
				if ( ! empty( $allowed ) && '' !== $value && ! in_array( $value, $allowed, true ) ) {
					return $field['default'];
				}

				return $value;

			case 'list':
				if ( ! is_array( $value ) ) {
					return self::NONE_SELECTED;
				}

				$value = array_values( array_filter( array_map( 'sanitize_text_field', $value ) ) );

				if ( ! empty( $field['choices'] ) && is_callable( $field['choices'] ) ) {
					$allowed = array_keys( (array) call_user_func( $field['choices'] ) );

					/*
					 * A choice list can shrink for reasons that have nothing to
					 * do with this save, and a name the browser was never
					 * offered cannot have been deselected in it. The thirteen
					 * WooCommerce builder widgets leave the catalogue whenever
					 * the builder is switched off; intersecting them away here
					 * would silently disable every one of them the next time
					 * anything on the Elements panel was saved, and switching
					 * the builder back on would find them all off.
					 *
					 * So: honour the submission for everything currently on
					 * offer, and carry the rest of the stored selection over
					 * untouched.
					 */
					$carried = is_array( $stored ) ? array_diff( $stored, $allowed ) : [];
					$value   = array_values(
						array_unique( array_merge( array_intersect( $value, $allowed ), $carried ) )
					);
				}

				// An empty selection is the string 'disabled', never an empty
				// array — consumers read a non-'disabled' non-array as "all on".
				return empty( $value ) ? self::NONE_SELECTED : $value;
		}

		if ( isset( $field['sanitize'] ) && is_callable( $field['sanitize'] ) ) {
			return call_user_func( $field['sanitize'], $value );
		}

		return $value;
	}

	/**
	 * An attachment ID or an image URL, and nothing else.
	 *
	 * Two shapes on purpose: the picker submits the ID of whatever was chosen
	 * from the media library, and someone whose logo lives on a CDN can paste a
	 * URL instead. A submitted ID is checked against the library rather than
	 * trusted, so a number that is not an attachment stores nothing rather than
	 * becoming a broken image on every screen.
	 *
	 * @since 3.0.0
	 * @param mixed $value Submitted value.
	 * @return string Attachment ID as a string, a URL, or ''.
	 */
	public static function sanitize_media( $value ) {
		$value = trim( (string) $value );

		if ( is_numeric( $value ) ) {
			$id = absint( $value );

			return $id && wp_attachment_is_image( $id ) ? (string) $id : '';
		}

		/*
		 * A scheme or a leading slash is required before esc_url_raw() sees it.
		 * On its own that function is a formatter rather than a test: given
		 * '<script>alert(1)</script>' it returns 'http://scriptalert(1)/script',
		 * which is harmless but is not a logo, and would be stored as though
		 * somebody meant it.
		 */
		if ( ! preg_match( '#^(https?:)?//|^/[^/]#', $value ) ) {
			return '';
		}

		return esc_url_raw( $value );
	}

	/**
	 * Whether a submitted value means "on".
	 *
	 * @since 3.0.0
	 * @param mixed $value Submitted value.
	 * @return bool
	 */
	private static function is_on( $value ) {
		if ( is_string( $value ) ) {
			return ! in_array( strtolower( $value ), [ '', '0', 'off', 'false', 'no', 'disabled' ], true );
		}

		return (bool) $value;
	}

	/**
	 * What hide_widget_help starts out as on a site that has never saved it.
	 *
	 * hide_support used to cover two unrelated things: the Support link, and the
	 * documentation links inside widgets. The second half is its own setting
	 * now, so a site that hid both under the one old switch has to go on hiding
	 * both, or an upgrade quietly hands those links back to a client who was
	 * never meant to see them. Once the new key is saved it stands on its own.
	 *
	 * Lives here rather than beside the other white label helpers because this
	 * is a field default, and because this file has to keep working without the
	 * rest of the plugin loaded.
	 *
	 * @since 3.0.0
	 * @return string 'on' or 'off'.
	 */
	public static function widget_help_default() {
		$settings = self::get_settings_array();

		if ( array_key_exists( 'hide_widget_help', $settings ) ) {
			return 'off';
		}

		return isset( $settings['hide_support'] ) && 'on' === $settings['hide_support'] ? 'on' : 'off';
	}

	/**
	 * Read the raw pp_elementor_settings array.
	 *
	 * Deliberately NOT PP_Admin_Settings::get_settings(): that method returns
	 * the array after the pp_elements_admin_settings filter has run, so seeding
	 * a save from it persists whatever a filter injected.
	 *
	 * @since 3.0.0
	 * @return array
	 */
	private static function get_settings_array() {
		$settings = get_option( self::SETTINGS_OPTION );

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

	/**
	 * Merge values into pp_elementor_settings.
	 *
	 * @since 3.0.0
	 * @param array $values Map of sub-key => value.
	 * @return void
	 */
	private static function write_settings_array( array $values ) {
		$settings = array_merge( self::get_settings_array(), $values );

		update_option( self::SETTINGS_OPTION, $settings );
	}

	/**
	 * Read an option, preserving the legacy multisite fallback.
	 *
	 * Mirrors PP_Admin_Settings::get_option(), but takes the network context
	 * explicitly rather than calling is_network_admin() — which is always false
	 * during a REST request.
	 *
	 * @since 3.0.0
	 * @param string $key              Option key.
	 * @param bool   $network_override Whether a site value may override the network one.
	 * @param bool   $network          Whether the request is in a network admin context.
	 * @return mixed
	 */
	private static function read_option( $key, $network_override, $network ) {
		if ( $network ) {
			return get_site_option( $key );
		}

		if ( ! $network_override && is_multisite() ) {
			return get_site_option( $key );
		}

		if ( $network_override && is_multisite() ) {
			$value = get_option( $key );

			if ( false === $value || ( is_array( $value ) && in_array( self::NONE_SELECTED, $value, true ) && 1 != get_option( 'pp_override_ms' ) ) ) {
				return get_site_option( $key );
			}

			return $value;
		}

		return get_option( $key );
	}

	/**
	 * Write an option.
	 *
	 * The legacy PP_Admin_Settings::update_option() inspected
	 * $_POST['pp_override_ms'] inside the storage helper and deleted the option
	 * when it was absent. No template ever rendered that checkbox and nothing
	 * writes the flag, so the branch is dropped here; the corresponding read
	 * fallback is preserved in read_option().
	 *
	 * @since 3.0.0
	 * @param string $key              Option key.
	 * @param mixed  $value            Value to store.
	 * @param bool   $network_override Whether a site value may override the network one.
	 * @param bool   $network          Whether the request is in a network admin context.
	 * @return void
	 */
	private static function write_option( $key, $value, $network_override, $network ) {
		if ( $network ) {
			update_site_option( $key, $value );

			return;
		}

		if ( ! $network_override && is_multisite() ) {
			update_site_option( $key, $value );

			return;
		}

		update_option( $key, $value );
	}

	/**
	 * Delete an option.
	 *
	 * @since 3.0.0
	 * @param string $key     Option key.
	 * @param bool   $network Whether the request is in a network admin context.
	 * @return void
	 */
	private static function delete_option( $key, $network ) {
		if ( $network ) {
			delete_site_option( $key );

			return;
		}

		delete_option( $key );
	}
}
