<?php
/**
 * Functions that don't require GravityView or Gravity Forms API access but are used in the plugin to extend PHP and WP functions
 *
 * @since 1.12
 */

if ( ! defined( 'GRAVITYVIEW_DIR' ) ) {
	return;
}

/**
 * Returns the current shortcode tag being executed.
 *
 * @since 2.45.1
 *
 * @param string $op  Operation: 'push' to add a tag, 'pop' to remove, 'top' to get current.
 * @param string $tag Shortcode tag to push (only used with 'push' operation).
 *
 * @return string|null Current shortcode tag or null if stack is empty.
 */
if ( ! function_exists( 'gv_current_shortcode_tag' ) ) {
	function gv_current_shortcode_tag( $op = 'top', $tag = null ) {
		static $stack = [];

		switch ( $op ) {
			case 'push':
				if ( is_string( $tag ) && '' !== $tag ) {
					$stack[] = $tag;
				}

				return end( $stack ) ?: null;
			case 'pop':
				array_pop( $stack );

				return end( $stack ) ?: null;
			default:
				return end( $stack ) ?: null;
		}
	}

	if ( function_exists( 'add_filter' ) ) {
		add_filter( 'pre_do_shortcode_tag', function ( $return, $tag ) {
			if ( false === $return ) {
				gv_current_shortcode_tag( 'push', $tag );
			}

			return $return;
		}, 10, 2 );

		add_filter( 'do_shortcode_tag', function ( $output, $tag ) {
			gv_current_shortcode_tag( 'pop' );

			return $output;
		}, 10, 2 );
	}
}

/**
 * Get the URL for a CSS file
 *
 * If there's a CSS file with the same name as a GravityView CSS file in the current theme directory, it will be used.
 * Place the CSS file in a `/gravityview/css/` sub-directory.
 *
 * Example: /twentysixteen/gravityview/css/gv-default-styles.css
 *
 * Will use, in order:
 * 1) [theme directory]/gravityview/css/
 * 2) [gravityview plugin]/css/ (no check performed)
 *
 * @since 1.17
 *
 * @uses get_stylesheet_directory()
 * @uses get_stylesheet_directory_uri()
 *
 * @param string $css_file Filename of the CSS file (like gv-default-styles.css)
 * @param string $dir_path Absolute path to the directory where the CSS file is stored. If empty, uses default GravityView templates CSS folder.
 *
 * @return string URL path to the file.
 */
if ( ! function_exists( 'gravityview_css_url' ) ) {
	function gravityview_css_url( $css_file = '', $dir_path = '' ) {

		// If there's an overriding CSS file in the current template folder, use it.
		$template_css_path = trailingslashit( get_stylesheet_directory() ) . 'gravityview/css/' . $css_file;

		if ( file_exists( $template_css_path ) ) {
			$path = trailingslashit( get_stylesheet_directory_uri() ) . 'gravityview/css/' . $css_file;
			\gravityview()->log->debug( 'Stylesheet override ({css_file})', array( 'css_file' => esc_attr( $css_file ) ) );
		} else {
			// Default: use GravityView CSS file

			// If no path is provided, assume default plugin templates CSS folder
			if ( '' === $dir_path ) {
				$dir_path = GRAVITYVIEW_DIR . 'templates/css/';
			}

			// plugins_url() expects a path to a file, not directory. We append a file to be stripped.
			$path = plugins_url( $css_file, trailingslashit( $dir_path ) . 'stripped-by-plugin_basename.php' );
		}

		return $path;
	}
}

/**
 * Check whether a variable is not an empty string
 *
 * @see /templates/fields/product.php Used to check whether the product array is empty or not
 *
 * @since 1.12
 *
 * @param mixed $mixed Variable to check
 *
 * @return bool true: $mixed is *not* an empty string; false: $mixed *is* an empty string
 */
if ( ! function_exists( 'gravityview_is_not_empty_string' ) ) {
	function gravityview_is_not_empty_string( $mixed = '' ) {
		return ( '' !== $mixed );
	}
}

/**
 * Get `get_permalink()` without the home_url() prepended to it.
 *
 * get_permalink() does a lot of good stuff: it gets the correct permalink structure for custom post types, pages,
 * posts, etc. Instead of using `?p={id}`, `?page_id={id}`, or `?p={id}&post_type={post_type}`, by using
 * get_permalink(), we can use `?p=slug` or `?gravityview={slug}`
 *
 * We could do this in a cleaner fashion, but this prevents a lot of code duplication, checking for URL structure, etc.
 *
 * @param int|WP_Post $id        Optional. Post ID or post object. Default current post.
 *
 * @return array URL args, if exists. Empty array if not.
 */
if ( ! function_exists( 'gravityview_get_permalink_query_args' ) ) {
	function gravityview_get_permalink_query_args( $id = 0 ) {

		$parsed_permalink = parse_url( get_permalink( $id ) );

		$permalink_args = isset( $parsed_permalink['query'] ) ? $parsed_permalink['query'] : false;

		if ( empty( $permalink_args ) ) {
			return array();
		}

		parse_str( $permalink_args, $args );

		return $args;
	}
}


/**
 * Similar to the WordPress `selected()`, `checked()`, and `disabled()` functions, except it allows arrays to be passed as current value
 *
 * @see selected() WordPress core function
 *
 * @param string $value One of the values to compare
 * @param mixed  $current (true) The other value to compare if not just true
 * @param bool   $echo Whether to echo or just return the string
 * @param string $type The type of checked|selected|disabled we are doing
 *
 * @return string html attribute or empty string
 */
if ( ! function_exists( 'gv_selected' ) ) {
	function gv_selected( $value, $current, $echo = true, $type = 'selected' ) {

		$output = '';
		if ( is_array( $current ) ) {
			if ( in_array( $value, $current ) ) {
				$output = __checked_selected_helper( true, true, false, $type );
			}
		} else {
			$output = __checked_selected_helper( $value, $current, false, $type );
		}

		if ( $echo ) {
			echo $output;
		}

		return $output;
	}
}


if ( ! function_exists( 'gravityview_sanitize_html_class' ) ) {

	/**
	 * sanitize_html_class doesn't handle spaces (multiple classes). We remedy that.
	 *
	 * @uses sanitize_html_class
	 *
	 * @param  string|array $classes Text or array of classes to sanitize
	 *
	 * @return string            Sanitized CSS string
	 */
	function gravityview_sanitize_html_class( $classes ) {

		if ( is_string( $classes ) ) {
			$classes = explode( ' ', $classes );
		}

		// If someone passes something not string or array, we get outta here.
		if ( ! is_array( $classes ) ) {
			return $classes;
		}

		$classes = array_map( 'trim', $classes );
		$classes = array_map( 'sanitize_html_class', $classes );
		$classes = array_filter( $classes );

		return implode( ' ', $classes );
	}
}

/**
 * Replace multiple newlines, tabs, and spaces with a single space
 *
 * First, runs normalize_whitespace() on a string. This replaces multiple lines with a single line, and tabs with spaces.
 * We then strip any tabs or newlines and replace *those* with a single space.
 *
 * @see normalize_whitespace()
 * @see GravityView_Helper_Functions_Test::test_gravityview_strip_whitespace
 * @since 1.13
 *
 * @param string $string String to strip whitespace from
 *
 * @return string Stripped string!
 */
if ( ! function_exists( 'gravityview_strip_whitespace' ) ) {
	function gravityview_strip_whitespace( $string ) {
		$string = normalize_whitespace( $string );
		return preg_replace( '/[\r\n\t ]+/', ' ', $string );
	}
}

/**
 * Get the contents of a file using `include()` and `ob_start()`
 *
 * @since 1.13
 * @since 1.15 Added $object param
 *
 * @param string $file_path Full path to a file
 * @param mixed  $object Pass pseudo-global to the included file
 * @return string Included file contents
 */
if ( ! function_exists( 'gravityview_ob_include' ) ) {
	function gravityview_ob_include( $file_path, $object = null ) {
		if ( ! file_exists( $file_path ) ) {
			\gravityview()->log->error( 'File path does not exist. {path}', array( 'path' => $file_path ) );
			return '';
		}
		ob_start();
		include $file_path;
		return ob_get_clean();
	}
}

/**
 * Get an image of our intrepid explorer friend
 *
 * @since 1.12
 * @since 2.1 Added $class parameter
 *
 * @param int         $height Height of the cutie in pixels
 * @param null|string $css_class If defined, use the passed CSS class (can be empty string). Otherwise, use default alignleft (or alignright, based on RTL).
 *
 * @return string HTML image tag with floaty's cute mug on it
 */
if ( ! function_exists( 'gravityview_get_floaty' ) ) {
	function gravityview_get_floaty( $height = 87, $css_class = null ) {

		$width = $height * 0.7586206897;

		if ( function_exists( 'is_rtl' ) && is_rtl() ) {
			$style     = 'margin:10px 10px 10px 0; height=' . $height . 'px; width: ' . $width . 'px;';
			$css_class = is_string( $css_class ) ? $css_class : 'alignright';
		} else {
			$style     = 'margin:10px 10px 10px 0; height=' . $height . 'px; width: ' . $width . 'px;';
			$css_class = is_string( $css_class ) ? $css_class : 'alignleft';
		}

		return '<img src="' . esc_url( \GravityKit\GravityView\Utils\Assets::url( 'images/astronaut-200x263.png' ) ) . '" class="' . gravityview_sanitize_html_class( $css_class ) . '" height="' . intval( $height ) . '" width="' . round( $width, 2 ) . '" alt="The GravityView Astronaut Says:" style="' . $style . '" />';
	}
}

/**
 * Intelligently format a number
 *
 * If you don't define the number of decimal places, then it will use the existing number of decimal places. This is done
 * in a way that respects the localization of the site.
 *
 * If you do define decimals, it uses number_format_i18n()
 *
 * @see number_format_i18n()
 *
 * @since 1.13
 *
 * @param int|float|string|double $number A number to format
 * @param int|string              $decimals Optional. Precision of the number of decimal places. Default '' (use existing number of decimals)
 * @param boolean                 $separator Separate with dots or commas, etc. Default: true.
 *
 * @return string Converted number in string format.
 */
if ( ! function_exists( 'gravityview_number_format' ) ) {
	function gravityview_number_format( $number, $decimals = '', $separator = true ) {
		global $wp_locale;

		if ( '' === $decimals ) {

			$decimal_point = isset( $wp_locale ) ? $wp_locale->number_format['decimal_point'] : '.';

			/**
			 * Calculate the position of the decimal point in the number
			 *
			 * @see http://stackoverflow.com/a/2430144/480856
			 */
			$decimals = strlen( substr( strrchr( $number, $decimal_point ), 1 ) );
		}

		if ( $separator ) {
			$number = number_format_i18n( $number, (int) $decimals );
		} else {
			$number = sprintf( "%.{$decimals}f", $number );
		}

		return $number;
	}
}


/**
 * Convert a whole link into a shorter link for display
 *
 * @since 1.1
 *
 * @param  string $value Existing URL
 * @return string        If parse_url doesn't find a 'host', returns original value. Otherwise, returns formatted link.
 */
if ( ! function_exists( 'gravityview_format_link' ) ) {
	function gravityview_format_link( $value = null ) {

		$parts = parse_url( $value );

		// No domain? Strange...show the original text.
		if ( empty( $parts['host'] ) ) {
			return $value;
		}

		// Start with empty value for the return URL
		$return = '';

		/**
		 * Strip scheme from the displayed URL?
		 *
		 * @since 1.5.1
		 *
		 * @param boolean $enable Whether to strip the scheme. Return false to show scheme. Default: true.
		 *                        If true: `http://example.com` => `example.com`
		 */
		if ( false === apply_filters( 'gravityview_anchor_text_striphttp', true ) ) {

			if ( isset( $parts['scheme'] ) ) {
				$return .= $parts['scheme'];
			}
		}

		// The domain, which may contain a subdomain
		$domain = $parts['host'];

		/**
		 * Strip www from the domain?
		 *
		 * @since 1.5.1
		 *
		 * @param boolean $enable Whether to strip www. Return false to show www. Default: true.
		 *                        If true: `www.example.com` => `example.com`
		 */
		$strip_www = apply_filters( 'gravityview_anchor_text_stripwww', true );

		if ( $strip_www ) {
			$domain = str_replace( 'www.', '', $domain );
		}

		/**
		 * Strip subdomains from the domain?
		 *
		 * @since 1.5.1
		 *
		 * @param boolean $enable Whether to strip subdomains. Return false to show subdomains. Default: true.
		 *                        If true: `http://demo.example.com` => `example.com`
		 *                        If false: `http://demo.example.com` => `demo.example.com`
		 */
		$strip_subdomains = apply_filters( 'gravityview_anchor_text_nosubdomain', true );

		if ( $strip_subdomains ) {

			$domain = _gravityview_strip_subdomain( $parts['host'] );

		}

		// Add the domain
		$return .= $domain;

		/**
		 * Display link path going only to the base directory, not a sub-directory or file?
		 *
		 * @since 1.5.1
		 *
		 * @param boolean $enable Whether to enable "root only". Return false to show full path. Default: true.
		 *                        If true: `http://example.com/sub/directory/page.html` => `example.com`
		 *                        If false: `http://example.com/sub/directory/page.html` => `example.com/sub/directory/page.html`
		 */
		$root_only = apply_filters( 'gravityview_anchor_text_rootonly', true );

		if ( empty( $root_only ) ) {

			if ( isset( $parts['path'] ) ) {
				$return .= $parts['path'];
			}
		}

		/**
		 * Strip the query string from the end of the URL?
		 *
		 * @since 1.5.1
		 *
		 * @param boolean $enable Whether to strip the query string. Return false to show query string. Default: true.
		 *                        If true: `http://example.com/?query=example` => `example.com`
		 */
		$strip_query_string = apply_filters( 'gravityview_anchor_text_noquerystring', true );

		if ( empty( $strip_query_string ) ) {

			if ( isset( $parts['query'] ) ) {
				$return .= '?' . $parts['query'];
			}
		}

		return $return;
	}
}

/**
 * Do a _very_ basic match for second-level TLD domains, like `.co.uk`
 *
 * Ideally, we'd use https://github.com/jeremykendall/php-domain-parser to check for this, but it's too much work for such a basic functionality. Maybe if it's needed more in the future. So instead, we use [Basic matching regex](http://stackoverflow.com/a/12372310).
 *
 * @param  string $domain Domain to check if it's a TLD or subdomain
 * @return string         Extracted domain if it has a subdomain
 */
if ( ! function_exists( '_gravityview_strip_subdomain' ) ) {
	function _gravityview_strip_subdomain( $string_maybe_has_subdomain ) {

		if ( preg_match( '/(?P<domain>[a-z0-9][a-z0-9\-]{1,63}\.(?:com\.|co\.|net\.|org\.|firm\.|me\.|school\.|law\.|gov\.|mod\.|msk\.|irkutsks\.|sa\.|act\.|police\.|plc\.|ac\.|tm\.|asso\.|biz\.|pro\.|cg\.|telememo\.)?[a-z\.]{2,6})$/i', $string_maybe_has_subdomain, $matches ) ) {
			return $matches['domain'];
		} else {
			return $string_maybe_has_subdomain;
		}
	}
}

/**
 * The inverse of gv_empty()
 *
 * @since 2.0
 *
 * @param mixed $value Check whether this is not empty
 * @param bool  $zero_is_empty Should the number zero be treated as an empty value? Default: `false`
 * @param bool  $allow_string_booleans Whether to check if 'yes', 'true' => `true` and 'no', 'false' => `false`. Default: `false`
 *
 * @return bool
 */
if ( ! function_exists( 'gv_not_empty' ) ) {
	function gv_not_empty( $value, $zero_is_empty = false, $allow_string_booleans = false ) {
		return ! gv_empty( $value, $zero_is_empty, $allow_string_booleans );
	}
}

/**
 * Is the value empty?
 *
 * Allows you to pass a function instead of just a variable, like the empty() function insists upon (until PHP 5.5)
 *
 * Checks whether `false`, `null`, empty string, empty array, object with no vars defined
 *
 * @since 1.15.1
 * @param  mixed   $value Check whether this is empty
 * @param boolean $zero_is_empty Should the number zero be treated as an empty value?
 * @param boolean $allow_string_booleans Whether to check if 'yes', 'true' => `true` and 'no', 'false' => `false`
 * @return boolean        True: empty; false: not empty
 */
if ( ! function_exists( 'gv_empty' ) ) {
	function gv_empty( $value, $zero_is_empty = true, $allow_string_booleans = true ) {

		/**
		 * Arrays with empty values are empty.
		 *
		 * Consider the a missing product field.
		 */
		if ( is_array( $value ) ) {
			$values = array();
			foreach ( $value as $v ) {
				if ( ! gv_empty( $v, $zero_is_empty, $allow_string_booleans ) ) {
					return false;
				}
			}
			return true;
		}

		if (
			! isset( $value ) // If it's not set, it's empty!
			|| false === $value
			|| null === $value
			|| '' === $value // Empty string
			|| array() === $value // Empty array
			|| ( is_object( $value ) && ! get_object_vars( $value ) ) // Empty object
		) {
			return true;
		}

		if ( is_string( $value ) && $allow_string_booleans ) {

			$value = trim( $value );
			$value = strtolower( $value );

			if ( in_array( $value, array( 'yes', 'true' ), true ) ) {
				$value = true;
			} elseif ( in_array( $value, array( 'no', 'false' ), true ) ) {
				$value = false;
			}
		}

		// If zero isn't empty, then if $value is a number and it's empty, it's zero. Thus, return false.
		if ( ! $zero_is_empty && is_numeric( $value ) && empty( $value ) ) {
			return false;
		}

		return empty( $value );
	}
}

/**
 * If content is JSON, decode it. Otherwise, return the passed value
 *
 * @since 2.0
 *
 * @see json_decode() for more information about the function parameters
 *
 * @param string $value The string that may be decoded
 * @param bool   $assoc [optional] When `true`, returned objects will be converted into associative arrays
 * @param int    $depth [optional] User specified recursion depth.
 * @param int    $options [optional] Bitmask of JSON decode options. Used only on sites running PHP 5.4+
 *
 * @return array|mixed|object|string If $value is JSON, returns the response from `json_decode()`. Otherwise, returns original value.
 */
if ( ! function_exists( 'gv_maybe_json_decode' ) ) {
	function gv_maybe_json_decode( $value, $assoc = false, $depth = 512, $options = 0 ) {

		if ( ! is_string( $value ) ) {
			return $value;
		}

		if ( version_compare( phpversion(), '5.4.0', '>=' ) ) {
			$decoded = json_decode( $value, $assoc, $depth, $options );
		} else {
			$decoded = json_decode( $value, $assoc, $depth );
		}

		// There was a JSON error (PHP 5.3+)
		if ( function_exists( 'json_last_error' ) && JSON_ERROR_NONE !== json_last_error() ) {
			return $value;
		}

		// It wasn't JSON (PHP < 5.3 fallback)
		if ( is_null( $decoded ) ) {
			return $value;
		}

		return $decoded;
	}
}


/**
 * Maps a function to all non-iterable elements of an array or an object.
 *
 * @uses map_deep()
 * @since 1.16.3
 * @since 2.29.0 Updated to filter `null` in callbacks that don't allow receiving `null` values.
 *
 * @param mixed    $value    The array, object, or scalar.
 * @param callable $callback The function to map onto $value.
 *
 * @return mixed The value with the callback applied to all non-arrays and non-objects inside it.
 */
if ( ! function_exists( 'gv_map_deep' ) ) {
	function gv_map_deep( $value, $callback ) {
		$_value = unserialize( serialize( $value ) ); // Make a deep copy to avoid overwriting the original object.

		$unsafe_callbacks = [
			'strtolower',
			'strtoupper',
			'rawurlencode',
			'rawurldecode',
			'urlencode',
			'urldecode',
			'base64_encode',
			'base64_decode',
			'get_headers',
			'mb_convert_encoding',
			'mb_detect_encoding',
			'mb_strlen',
			'mb_strpos',
			'mb_strrpos',
			'mb_substr',
			'mb_str_split',
		];

		if ( in_array( $callback, $unsafe_callbacks, true ) ) {
			return map_deep( $_value, function ( $item ) use ( $callback ) {
				return is_null( $item ) ? $item : $callback( $item );
			} );
		}

		return map_deep( $_value, $callback );
	}
}

/**
 * Check whether a string is a expected date format
 *
 * @since 1.15.2
 *
 * @param string $datetime The date to check
 * @param string $expected_format Check whether the date is formatted as expected. Default: Y-m-d
 *
 * @return bool True: it's a valid datetime, formatted as expected. False: it's not a date formatted as expected.
 */
if ( ! function_exists( 'gravityview_is_valid_datetime' ) ) {
	function gravityview_is_valid_datetime( $datetime, $expected_format = 'Y-m-d' ) {

		/**
		 * @var bool|DateTime False if not a valid date, (like a relative date). DateTime if a date was created.
		 */
		$formatted_date = DateTime::createFromFormat( $expected_format, $datetime );

		/**
		 * @see http://stackoverflow.com/a/19271434/480856
		 */
		return ( $formatted_date && $formatted_date->format( $expected_format ) === $datetime );
	}
}

/**
 * Very commonly needed: get the # of the input based on a full field ID.
 *
 * Example: 12.3 => field #12, input #3. Returns: 3
 * Example: 7 => field #7, no input. Returns: 0
 *
 * @since 1.16.4
 *
 * @param string $field_id Full ID of field, with or without input ID, like "12.3" or "7".
 *
 * @return int If field ID has an input, returns that input number. Otherwise, returns false.
 */
if ( ! function_exists( 'gravityview_get_input_id_from_id' ) ) {
	function gravityview_get_input_id_from_id( $field_id = '' ) {

		if ( ! is_numeric( $field_id ) ) {
			\gravityview()->log->error( '$field_id not numeric', array( 'data' => $field_id ) );
			return false;
		}

		$exploded = explode( '.', "{$field_id}" );

		return isset( $exploded[1] ) ? intval( $exploded[1] ) : false;
	}
}

/**
 * Get categories formatted in a way used by GravityView and Gravity Forms input choices
 *
 * @since 1.15.3
 *
 * @see get_terms()
 *
 * @param array $args Arguments array as used by the get_terms() function. Filtered using `gravityview_get_terms_choices_args` filter. Defaults: { \n
 *   @type string $taxonomy Used as first argument in get_terms(). Default: "category"
 *   @type string $fields Default: 'id=>name' to only fetch term ID and Name \n
 *   @type int $number  Limit the total number of terms to fetch. Default: 1000 \n
 * }
 *
 * @return array Multidimensional array with `text` (Category Name) and `value` (Category ID) keys.
 */
if ( ! function_exists( 'gravityview_get_terms_choices' ) ) {
	function gravityview_get_terms_choices( $args = array() ) {

		$defaults = array(
			'type'         => 'post',
			'child_of'     => 0,
			'number'       => 1000, // Set a reasonable max limit
			'orderby'      => 'name',
			'order'        => 'ASC',
			'hide_empty'   => 0,
			'hierarchical' => 1,
			'taxonomy'     => 'category',
			'fields'       => 'id=>name',
		);

		$args = wp_parse_args( $args, $defaults );

		/**
		 * Modify the arguments passed to `get_terms()`.
		 *
		 * @since 1.15.3
		 *
		 * @see get_terms()
		 *
		 * @param array $args Arguments array as used by the get_terms() function.
		 */
		$args = apply_filters( 'gravityview_get_terms_choices_args', $args );

		$terms = get_terms( $args['taxonomy'], $args );

		$choices = array();

		if ( is_array( $terms ) ) {
			foreach ( $terms as $term_id => $term_name ) {
				$choices[] = array(
					'text'  => $term_name,
					'value' => $term_id,
				);
			}
		}

		return $choices;
	}
}

/**
 * Maybe convert jQuery-serialized fields into array, otherwise return $_POST['fields'] array
 *
 * Fields are passed as a jQuery-serialized array, created in admin-views.js in the serializeForm method.
 *
 * @since 1.16.5
 *
 * @uses GVCommon::gv_parse_str
 *
 * @return array Array of fields
 */
if ( ! function_exists( '_gravityview_process_posted_fields' ) ) {
	function _gravityview_process_posted_fields() {
		$fields = array();

		if ( ! empty( $_POST['gv_fields'] ) ) {
			if ( ! is_array( $_POST['gv_fields'] ) ) {

				// We are not using parse_str() due to max_input_vars limitation with large View configurations
				$fields_holder = array();
				GVCommon::gv_parse_str( stripslashes( $_POST['gv_fields'] ), $fields_holder );

				if ( isset( $fields_holder['fields'] ) ) {
					$fields = $fields_holder['fields'];
				} else {
					\gravityview()->log->error( 'No `fields` key was found after parsing $fields string', array( 'data' => $fields_holder ) );
				}
			} else {
				$fields = $_POST['gv_fields'];
			}
		}

		return $fields;
	}
}

/**
 * Convert a string to a timestamp if it's a valid date or a relative date prefixed with "relative:".
 *
 * For example: "relative:-1 week" or "relative:tomorrow" will be converted to a timestamp.
 *
 * Note: This assumes the date is using a Gregorian calendar.
 *
 * @since 2.14.8
 *
 * @uses GFCommon::parse_date()
 * @uses DateTimeImmutable::createFromFormat()
 *
 * @uses strtotime()
 *
 * @param string $value The string to convert to a timestamp. Supports relative dates and different date formats. Does its best to figure out the date format.
 *
 * @return false|int Timestamp if valid date, otherwise false.
 */
if ( ! function_exists( 'gravityview_maybe_convert_date_string_to_timestamp' ) ) {
	function gravityview_maybe_convert_date_string_to_timestamp( $value = '' ) {

		if ( ! is_string( $value ) ) {
			return false;
		}

		if ( false !== strpos( strtolower( $value ), 'relative:' ) ) {
			$value = str_replace( 'relative:', '', $value );

			$timestamp = strtotime( $value );

			// It's a valid date/time.
			if ( $timestamp ) {
				return $timestamp;
			}
		}

		// Attempt to parse dates.
		$parsed_date = array_filter( \GFCommon::parse_date( $value ) );

		// Should always have year/month/day.
		if ( 3 !== sizeof( $parsed_date ) ) {
			return false;
		}

		$year  = (int) rgar( $parsed_date, 'year' );
		$month = (int) rgar( $parsed_date, 'month' );
		$day   = (int) rgar( $parsed_date, 'day' );

		// If the month is greater than 12, it's probably a day (in d/m/Y format). This may be why strtotime() failed.
		// Let's correct this by swapping the day and month.
		if ( ! checkdate( $month, $day, $year ) ) {
			$day   = (int) rgar( $parsed_date, 'month' );
			$month = (int) rgar( $parsed_date, 'day' );
		}

		// Still not valid. Perhaps not a date.
		if ( ! checkdate( $month, $day, $year ) ) {
			return false;
		}

		// Need to set time to midnight so date comparisons work properly.
		$date = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', "$year-$month-$day 00:00:00" );

		if ( ! $date ) {
			return false;
		}

		return $date->getTimestamp();
	}
}

/**
 * A polyfill for json_validate which is introduced in PHP 8.3.
 * @since 2.42
 */
if ( ! function_exists( 'json_validate' ) ) {
	/**
	 * Validates a JSON string.
	 *
	 * @param string $json  The JSON string to validate.
	 * @param int    $depth Maximum depth. Must be greater than zero.
	 * @param int    $flags Bitmask of JSON decode options.
	 *
	 * @return bool Returns true if the string is a valid JSON, otherwise false.
	 */
	function json_validate( string $json, int $depth = 512, int $flags = 0 ): bool {
		try {
			json_decode( $json, false, $depth, $flags | JSON_THROW_ON_ERROR );

			return true;
		} catch ( \JsonException $e ) {
			return false;
		}
	}
}
