<?php
/**
 * RSM Editor sanitizer and helpers.
 *
 * @package Rsmspark_Editor
 */

defined( 'ABSPATH' ) || exit;

/**
 * Rsmspark_Rich_Text
 */
final class Rsmspark_Rich_Text {

	/**
	 * Sanitize rich HTML for a profile.
	 *
	 * @param mixed  $html    Raw HTML.
	 * @param string $profile Profile key.
	 * @return string
	 */
	public static function sanitize( $html, $profile ) {
		$profile = sanitize_key( (string) $profile );
		if ( ! Rsmspark_Rich_Text_Registry::is_valid_profile( $profile ) ) {
			return '';
		}

		$html = (string) $html;
		if ( '' === trim( $html ) ) {
			return '';
		}

		$html = self::normalize_legacy_breaks( $html );
		$html = wp_kses( $html, Rsmspark_Rich_Text_Registry::allowed_tags( $profile ) );
		$html = self::sanitize_inline_styles( $html );
		$html = self::strip_disallowed_classes( $html, $profile );
		$html = self::normalize_list_markup( $html );
		$html = self::normalize_legacy_tags( $html );

		return trim( $html );
	}

	/**
	 * Whether sanitized HTML contains visible text.
	 *
	 * @param string $html Sanitized HTML.
	 * @return bool
	 */
	public static function has_text( $html ) {
		return '' !== trim( wp_strip_all_tags( (string) $html ) );
	}

	/**
	 * Whether HTML uses rich-text tokens (for asset loading).
	 *
	 * @param string $html Raw or sanitized HTML.
	 * @return bool
	 */
	public static function contains_tokens( $html ) {
		return (bool) preg_match( '/\brsmspark-rt-/', (string) $html );
	}

	/**
	 * @param string $class   Class token.
	 * @param string $profile Profile key.
	 * @return bool
	 */
	public static function class_is_allowed( $class, $profile ) {
		$class = sanitize_html_class( (string) $class );
		if ( '' === $class ) {
			return false;
		}

		return in_array( $class, Rsmspark_Rich_Text_Registry::allowed_classes( $profile ), true );
	}

	/**
	 * @param string $html HTML.
	 * @return string
	 */
	private static function normalize_legacy_breaks( $html ) {
		$html = str_replace( array( "\r\n", "\r" ), "\n", (string) $html );
		if ( false === strpos( $html, '<' ) ) {
			return str_replace( "\n", '<br />', $html );
		}

		return $html;
	}

	/**
	 * Keep only safe font-size / color declarations on span elements.
	 *
	 * @param string $html HTML.
	 * @return string
	 */
	private static function sanitize_inline_styles( $html ) {
		if ( '' === trim( (string) $html ) || ! class_exists( 'DOMDocument', false ) ) {
			return (string) $html;
		}

		$limits  = Rsmspark_Rich_Text_Registry::custom_size_limits();
		$min_px  = (int) $limits['min'];
		$max_px  = (int) $limits['max'];
		$doc     = new DOMDocument();
		libxml_use_internal_errors( true );
		$wrapped = '<div id="rsmspark-rt-wrap">' . $html . '</div>';
		$doc->loadHTML( '<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();

		$wrap = $doc->getElementById( 'rsmspark-rt-wrap' );
		if ( ! $wrap ) {
			return (string) $html;
		}

		/** @var DOMElement $node */
		foreach ( $doc->getElementsByTagName( 'span' ) as $node ) {
			if ( ! $node->hasAttribute( 'style' ) ) {
				continue;
			}

			$safe = self::sanitize_style_attribute( (string) $node->getAttribute( 'style' ), $min_px, $max_px );
			if ( '' === $safe ) {
				$node->removeAttribute( 'style' );
			} else {
				$node->setAttribute( 'style', $safe );
			}
		}

		foreach ( array( 'ul', 'ol' ) as $tag ) {
			/** @var DOMElement $node */
			foreach ( $doc->getElementsByTagName( $tag ) as $node ) {
				if ( ! $node->hasAttribute( 'style' ) ) {
					continue;
				}

				$safe = self::sanitize_list_marker_style( (string) $node->getAttribute( 'style' ) );
				if ( '' === $safe ) {
					$node->removeAttribute( 'style' );
				} else {
					$node->setAttribute( 'style', $safe );
				}
			}
		}

		$clean = '';
		foreach ( $wrap->childNodes as $child ) {
			$clean .= $doc->saveHTML( $child );
		}

		return $clean;
	}

	/**
	 * @param string $style Raw style attribute.
	 * @param int    $min_px Minimum font-size px.
	 * @param int    $max_px Maximum font-size px.
	 * @return string
	 */
	private static function sanitize_style_attribute( $style, $min_px, $max_px ) {
		$allowed = array();
		$parts   = explode( ';', strtolower( (string) $style ) );

		foreach ( $parts as $part ) {
			$part = trim( $part );
			if ( '' === $part || false === strpos( $part, ':' ) ) {
				continue;
			}

			list( $prop, $value ) = array_map( 'trim', explode( ':', $part, 2 ) );
			$value                = trim( $value, " \t\n\r\0\x0B\"'" );

			if ( 'font-size' === $prop && preg_match( '/^(\d{1,3})px$/', $value, $matches ) ) {
				$px = (int) $matches[1];
				if ( $px >= $min_px && $px <= $max_px ) {
					$allowed['font-size'] = $px . 'px';
				}
				continue;
			}

			if ( 'color' === $prop ) {
				$hex = self::normalize_hex_color( $value );
				if ( '' !== $hex ) {
					$allowed['color'] = $hex;
				}
				continue;
			}

			if ( 'background-color' === $prop ) {
				$hex = self::normalize_hex_color( $value );
				if ( '' !== $hex ) {
					$allowed['background-color'] = $hex;
				}
				continue;
			}

			if ( 'font-family' === $prop ) {
				$family = Rsmspark_Rich_Text_Standards::sanitize_font_family_stack( $value );
				if ( '' !== $family ) {
					$allowed['font-family'] = $family;
				}
			}
		}

		$declarations = array();
		if ( isset( $allowed['font-size'] ) ) {
			$declarations[] = 'font-size:' . $allowed['font-size'];
		}
		if ( isset( $allowed['color'] ) ) {
			$declarations[] = 'color:' . $allowed['color'];
		}
		if ( isset( $allowed['background-color'] ) ) {
			$declarations[] = 'background-color:' . $allowed['background-color'];
		}
		if ( isset( $allowed['font-family'] ) ) {
			$declarations[] = 'font-family:' . $allowed['font-family'];
		}

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

	/**
	 * Keep only a custom list-marker color on ul/ol elements.
	 *
	 * @param string $style Raw style attribute.
	 * @return string
	 */
	private static function sanitize_list_marker_style( $style ) {
		$parts = explode( ';', strtolower( (string) $style ) );

		foreach ( $parts as $part ) {
			$part = trim( $part );
			if ( '' === $part || false === strpos( $part, ':' ) ) {
				continue;
			}

			list( $prop, $value ) = array_map( 'trim', explode( ':', $part, 2 ) );
			$value                = trim( $value, " \t\n\r\0\x0B\"'" );

			if ( '--rsmspark-rt-list-marker-color' !== $prop ) {
				continue;
			}

			$hex = self::normalize_hex_color( $value );
			if ( '' !== $hex ) {
				return '--rsmspark-rt-list-marker-color:' . $hex;
			}
		}

		return '';
	}

	/**
	 * @param string $value Color value.
	 * @return string Normalized #rrggbb or empty.
	 */
	private static function normalize_hex_color( $value ) {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}

		if ( '#' !== $value[0] ) {
			$value = '#' . $value;
		}

		if ( ! preg_match( '/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value, $matches ) ) {
			return '';
		}

		$hex = strtolower( $matches[1] );
		if ( 3 === strlen( $hex ) ) {
			$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
		}

		return '#' . $hex;
	}

	/**
	 * @param string $html    HTML.
	 * @param string $profile Profile key.
	 * @return string
	 */
	private static function strip_disallowed_classes( $html, $profile ) {
		if ( '' === trim( (string) $html ) || ! class_exists( 'DOMDocument', false ) ) {
			return (string) $html;
		}

		$allowed = Rsmspark_Rich_Text_Registry::allowed_classes( $profile );
		$doc     = new DOMDocument();
		libxml_use_internal_errors( true );
		$wrapped = '<div id="rsmspark-rt-wrap">' . $html . '</div>';
		$doc->loadHTML( '<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();

		$wrap = $doc->getElementById( 'rsmspark-rt-wrap' );
		if ( ! $wrap ) {
			return (string) $html;
		}

		/** @var DOMElement $node */
		foreach ( $doc->getElementsByTagName( '*' ) as $node ) {
			if ( ! $node->hasAttribute( 'class' ) ) {
				continue;
			}

			$classes = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ) );
			$classes = array_values(
				array_filter(
					array_map(
						static function ( $class ) use ( $allowed ) {
							$class = sanitize_html_class( (string) $class );
							return in_array( $class, $allowed, true ) ? $class : '';
						},
						is_array( $classes ) ? $classes : array()
					)
				)
			);

			if ( empty( $classes ) ) {
				$node->removeAttribute( 'class' );
			} else {
				$node->setAttribute( 'class', implode( ' ', $classes ) );
			}
		}

		$clean = '';
		foreach ( $wrap->childNodes as $child ) {
			$clean .= $doc->saveHTML( $child );
		}

		return $clean;
	}

	/**
	 * Ensure list token classes form a valid, renderable combination.
	 *
	 * @param string $html HTML.
	 * @return string
	 */
	private static function normalize_list_markup( $html ) {
		if ( '' === trim( (string) $html ) || ! class_exists( 'DOMDocument', false ) ) {
			return (string) $html;
		}

		$marker_map     = Rsmspark_Rich_Text_Registry::list_marker_color_map();
		$style_modifiers = Rsmspark_Rich_Text_Registry::list_style_modifier_classes();
		$doc            = new DOMDocument();
		libxml_use_internal_errors( true );
		$wrapped = '<div id="rsmspark-rt-wrap">' . $html . '</div>';
		$doc->loadHTML( '<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();

		$wrap = $doc->getElementById( 'rsmspark-rt-wrap' );
		if ( ! $wrap ) {
			return (string) $html;
		}

		foreach ( array( 'ul', 'ol' ) as $tag ) {
			/** @var DOMElement $node */
			foreach ( $doc->getElementsByTagName( $tag ) as $node ) {
				$classes = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ) );
				$classes = is_array( $classes ) ? array_values( array_filter( $classes ) ) : array();

				$has_list_token = false;
				foreach ( $classes as $class ) {
					if ( 0 === strpos( $class, 'rsmspark-rt-list' ) ) {
						$has_list_token = true;
						break;
					}
				}

				if ( ! $has_list_token ) {
					continue;
				}

				$style_map   = array();
				$style_attr  = trim( (string) $node->getAttribute( 'style' ) );
				if ( '' !== $style_attr ) {
					foreach ( explode( ';', $style_attr ) as $part ) {
						$part = trim( $part );
						if ( '' === $part || false === strpos( $part, ':' ) ) {
							continue;
						}
						list( $prop, $value ) = array_map( 'trim', explode( ':', $part, 2 ) );
						if ( '' !== $prop ) {
							$style_map[ strtolower( $prop ) ] = $value;
						}
					}
				}

				$has_style_modifier = false;
				$marker_hex         = '';

				foreach ( $classes as $index => $class ) {
					if ( in_array( $class, $style_modifiers, true ) ) {
						$has_style_modifier = true;
					}

					if ( isset( $marker_map[ $class ] ) ) {
						$marker_hex = $marker_map[ $class ];
						unset( $classes[ $index ] );
					}
				}

				if ( '' !== $marker_hex && ! isset( $style_map['--rsmspark-rt-list-marker-color'] ) ) {
					$style_map['--rsmspark-rt-list-marker-color'] = $marker_hex;
				}

				if ( ! in_array( 'rsmspark-rt-list', $classes, true ) ) {
					array_unshift( $classes, 'rsmspark-rt-list' );
				}

				if ( ! $has_style_modifier ) {
					$classes[] = 'rsmspark-rt-list--bullet';
				}

				$classes = array_values( array_unique( array_filter( $classes ) ) );
				$node->setAttribute( 'class', implode( ' ', $classes ) );

				$declarations = array();
				foreach ( $style_map as $prop => $value ) {
					if ( '--rsmspark-rt-list-marker-color' === $prop ) {
						$hex = self::normalize_hex_color( $value );
						if ( '' !== $hex ) {
							$declarations[] = '--rsmspark-rt-list-marker-color:' . $hex;
						}
						continue;
					}
				}

				if ( empty( $declarations ) ) {
					$node->removeAttribute( 'style' );
				} else {
					$node->setAttribute( 'style', implode( ';', $declarations ) );
				}
			}
		}

		$clean = '';
		foreach ( $wrap->childNodes as $child ) {
			$clean .= $doc->saveHTML( $child );
		}

		return $clean;
	}

	/**
	 * @param string $html HTML.
	 * @return string
	 */
	private static function normalize_legacy_tags( $html ) {
		$html = (string) preg_replace( '#</(b|i)>#i', '</$1>', $html );
		$html = str_ireplace( array( '<b>', '<i>' ), array( '<strong>', '<em>' ), $html );
		$html = str_ireplace( array( '</b>', '</i>' ), array( '</strong>', '</em>' ), $html );

		return $html;
	}
}
