<?php
/**
 * Customize API: WP_Customize_Custom_CSS_Setting class
 *
 * This handles validation, sanitization and saving of the value.
 *
 * @package WordPress
 * @subpackage Customize
 * @since 4.7.0
 */

/**
 * Custom Setting to handle WP Custom CSS.
 *
 * @since 4.7.0
 *
 * @see WP_Customize_Setting
 */
final class WP_Customize_Custom_CSS_Setting extends WP_Customize_Setting {

	/**
	 * The setting type.
	 *
	 * @since 4.7.0
	 * @access public
	 * @var string
	 */
	public $type = 'custom_css';

	/**
	 * Setting Transport
	 *
	 * @since 4.7.0
	 * @access public
	 * @var string
	 */
	public $transport = 'postMessage';

	/**
	 * Capability required to edit this setting.
	 *
	 * @since 4.7.0
	 * @access public
	 * @var string
	 */
	public $capability = 'edit_css';

	/**
	 * Stylesheet
	 *
	 * @since 4.7.0
	 * @access public
	 * @var string
	 */
	public $stylesheet = '';

	/**
	 * WP_Customize_Custom_CSS_Setting constructor.
	 *
	 * @since 4.7.0
	 * @access public
	 *
	 * @throws Exception If the setting ID does not match the pattern `custom_css[$stylesheet]`.
	 *
	 * @param WP_Customize_Manager $manager The Customize Manager class.
	 * @param string               $id      An specific ID of the setting. Can be a
	 *                                      theme mod or option name.
	 * @param array                $args    Setting arguments.
	 */
	public function __construct( $manager, $id, $args = array() ) {
		parent::__construct( $manager, $id, $args );
		if ( 'custom_css' !== $this->id_data['base'] ) {
			throw new Exception( 'Expected custom_css id_base.' );
		}
		if ( 1 !== count( $this->id_data['keys'] ) || empty( $this->id_data['keys'][0] ) ) {
			throw new Exception( 'Expected single stylesheet key.' );
		}
		$this->stylesheet = $this->id_data['keys'][0];
	}

	/**
	 * Add filter to preview post value.
	 *
	 * @since 4.7.9
	 * @access public
	 *
	 * @return bool False when preview short-circuits due no change needing to be previewed.
	 */
	public function preview() {
		if ( $this->is_previewed ) {
			return false;
		}
		$this->is_previewed = true;
		add_filter( 'wp_get_custom_css', array( $this, 'filter_previewed_wp_get_custom_css' ), 9, 2 );
		return true;
	}

	/**
	 * Filter `wp_get_custom_css` for applying the customized value.
	 *
	 * This is used in the preview when `wp_get_custom_css()` is called for rendering the styles.
	 *
	 * @since 4.7.0
	 * @access private
	 * @see wp_get_custom_css()
	 *
	 * @param string $css        Original CSS.
	 * @param string $stylesheet Current stylesheet.
	 * @return string CSS.
	 */
	public function filter_previewed_wp_get_custom_css( $css, $stylesheet ) {
		if ( $stylesheet === $this->stylesheet ) {
			$customized_value = $this->post_value( null );
			if ( ! is_null( $customized_value ) ) {
				$css = $customized_value;
			}
		}
		return $css;
	}

	/**
	 * Fetch the value of the setting. Will return the previewed value when `preview()` is called.
	 *
	 * @since 4.7.0
	 * @access public
	 * @see WP_Customize_Setting::value()
	 *
	 * @return string
	 */
	public function value() {
		if ( $this->is_previewed ) {
			$post_value = $this->post_value( null );
			if ( null !== $post_value ) {
				return $post_value;
			}
		}
		$id_base = $this->id_data['base'];
		$value = '';
		$post = wp_get_custom_css_post( $this->stylesheet );
		if ( $post ) {
			$value = $post->post_content;
		}
		if ( empty( $value ) ) {
			$value = $this->default;
		}

		/** This filter is documented in wp-includes/class-wp-customize-setting.php */
		$value = apply_filters( "customize_value_{$id_base}", $value, $this );

		return $value;
	}

	/**
	 * Validate CSS.
	 *
	 * Checks for imbalanced braces, brackets, and comments.
	 * Notifications are rendered when the customizer state is saved.
	 *
	 * @todo There are cases where valid CSS can be incorrectly marked as invalid when strings or comments include balancing characters. To fix, CSS tokenization needs to be used.
	 *
	 * @since 4.7.0
	 * @access public
	 *
	 * @param string $css The input string.
	 * @return true|WP_Error True if the input was validated, otherwise WP_Error.
	 */
	public function validate( $css ) {
		$validity = new WP_Error();

		if ( preg_match( '#</?\w+#', $css ) ) {
			$validity->add( 'illegal_markup', __( 'Markup is not allowed in CSS.' ) );
		}

		$imbalanced = false;

		// Make sure that there is a closing brace for each opening brace.
		if ( ! $this->validate_balanced_characters( '{', '}', $css ) ) {
			$validity->add( 'imbalanced_curly_brackets', __( 'Your curly brackets <code>{}</code> are imbalanced. Make sure there is a closing <code>}</code> for every opening <code>{</code>.' ) );
			$imbalanced = true;
		}

		// Ensure brackets are balanced.
		if ( ! $this->validate_balanced_characters( '[', ']', $css ) ) {
			$validity->add( 'imbalanced_braces', __( 'Your brackets <code>[]</code> are imbalanced. Make sure there is a closing <code>]</code> for every opening <code>[</code>.' ) );
			$imbalanced = true;
		}

		// Ensure parentheses are balanced.
		if ( ! $this->validate_balanced_characters( '(', ')', $css ) ) {
			$validity->add( 'imbalanced_parentheses', __( 'Your parentheses <code>()</code> are imbalanced. Make sure there is a closing <code>)</code> for every opening <code>(</code>.' ) );
			$imbalanced = true;
		}

		// Ensure double quotes are equal.
		if ( ! $this->validate_equal_characters( '"', $css ) ) {
			$validity->add( 'unequal_double_quotes', __( 'Your double quotes <code>"</code> are uneven. Make sure there is a closing <code>"</code> for every opening <code>"</code>.' ) );
			$imbalanced = true;
		}

		/*
		 * Make sure any code comments are closed properly.
		 *
		 * The first check could miss stray an unpaired comment closing figure, so if
		 * The number appears to be balanced, then check for equal numbers
		 * of opening/closing comment figures.
		 *
		 * Although it may initially appear redundant, we use the first method
		 * to give more specific feedback to the user.
		 */
		$unclosed_comment_count = $this->validate_count_unclosed_comments( $css );
		if ( 0 < $unclosed_comment_count ) {
			$validity->add( 'unclosed_comment', sprintf( _n( 'There is %s unclosed code comment. Close each comment with <code>*/</code>.', 'There are %s unclosed code comments. Close each comment with <code>*/</code>.', $unclosed_comment_count ), $unclosed_comment_count ) );
			$imbalanced = true;
		} elseif ( ! $this->validate_balanced_characters( '/*', '*/', $css ) ) {
			$validity->add( 'imbalanced_comments', __( 'There is an extra <code>*/</code>, indicating an end to a comment.  Be sure that there is an opening <code>/*</code> for every closing <code>*/</code>.' ) );
			$imbalanced = true;
		}
		if ( $imbalanced && $this->is_possible_content_error( $css ) ) {
			$validity->add( 'possible_false_positive', __( 'Imbalanced/unclosed character errors can be caused by <code>content: "";</code> declarations. You may need to remove this or add it to a custom CSS file.' ) );
		}

		if ( empty( $validity->errors ) ) {
			$validity = parent::validate( $css );
		}
		return $validity;
	}

	/**
	 * Store the CSS setting value in the custom_css custom post type for the stylesheet.
	 *
	 * @since 4.7.0
	 * @access public
	 *
	 * @param string $css The input value.
	 * @return int|false The post ID or false if the value could not be saved.
	 */
	public function update( $css ) {
		if ( empty( $css ) ) {
			$css = '';
		}

		$r = wp_update_custom_css_post( $css, array(
			'stylesheet' => $this->stylesheet,
		) );

		if ( $r instanceof WP_Error ) {
			return false;
		}
		$post_id = $r->ID;

		// Cache post ID in theme mod for performance to avoid additional DB query.
		if ( $this->manager->get_stylesheet() === $this->stylesheet ) {
			set_theme_mod( 'custom_css_post_id', $post_id );
		}

		return $post_id;
	}

	/**
	 * Ensure there are a balanced number of paired characters.
	 *
	 * This is used to check that the number of opening and closing
	 * characters is equal.
	 *
	 * For instance, there should be an equal number of braces ("{", "}")
	 * in the CSS.
	 *
	 * @since 4.7.0
	 * @access private
	 *
	 * @param string $opening_char The opening character.
	 * @param string $closing_char The closing character.
	 * @param string $css The CSS input string.
	 *
	 * @return bool
	 */
	private function validate_balanced_characters( $opening_char, $closing_char, $css ) {
		return substr_count( $css, $opening_char ) === substr_count( $css, $closing_char );
	}

	/**
	 * Ensure there are an even number of paired characters.
	 *
	 * This is used to check that the number of a specific
	 * character is even.
	 *
	 * For instance, there should be an even number of double quotes
	 * in the CSS.
	 *
	 * @since 4.7.0
	 * @access private
	 *
	 * @param string $char A character.
	 * @param string $css The CSS input string.
	 * @return bool Equality.
	 */
	private function validate_equal_characters( $char, $css ) {
		$char_count = substr_count( $css, $char );
		return ( 0 === $char_count % 2 );
	}

	/**
	 * Count unclosed CSS Comments.
	 *
	 * Used during validation.
	 *
	 * @see self::validate()
	 *
	 * @since 4.7.0
	 * @access private
	 *
	 * @param string $css The CSS input string.
	 * @return int Count.
	 */
	private function validate_count_unclosed_comments( $css ) {
		$count = 0;
		$comments = explode( '/*', $css );

		if ( ! is_array( $comments ) || ( 1 >= count( $comments ) ) ) {
			return $count;
		}

		unset( $comments[0] ); // The first item is before the first comment.
		foreach ( $comments as $comment ) {
			if ( false === strpos( $comment, '*/' ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Find "content:" within a string.
	 *
	 * Imbalanced/Unclosed validation errors may be caused
	 * when a character is used in a "content:" declaration.
	 *
	 * This function is used to detect if this is a possible
	 * cause of the validation error, so that if it is,
	 * a notification may be added to the Validation Errors.
	 *
	 * Example:
	 * .element::before {
	 *   content: "(\"";
	 * }
	 * .element::after {
	 *   content: "\")";
	 * }
	 *
	 * Using ! empty() because strpos() may return non-boolean values
	 * that evaluate to false. This would be problematic when
	 * using a strict "false === strpos()" comparison.
	 *
	 * @since 4.7.0
	 * @access private
	 *
	 * @param string $css The CSS input string.
	 * @return bool
	 */
	private function is_possible_content_error( $css ) {
		$found = preg_match( '/\bcontent\s*:/', $css );
		if ( ! empty( $found ) ) {
			return true;
		}
		return false;
	}
}
