<?php
/**
 * Server-authoritative FAQ block attribute sanitizer.
 *
 * @package SEOvaultSEO\Blocks
 */

namespace SEOvaultSEO\Blocks;

use SEOvaultSEO\Schema\SchemaInstanceStorage;
use SEOvaultSEO\Schema\SchemaSourceDescriptor;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Sanitizes and normalizes seovault/faq block attributes.
 */
class FaqBlockAttributeSanitizer {

	/**
	 * Supported schemaVersion values.
	 */
	const SCHEMA_VERSION = 1;

	/**
	 * Default attributes for a new block.
	 *
	 * @return array<string,mixed>
	 */
	public static function defaults() {
		return array(
			'schemaVersion'        => self::SCHEMA_VERSION,
			'heading'              => '',
			'intro'                => '',
			'items'                => array(),
			'displayMode'          => 'accordion',
			'stylePreset'          => 'accordion-cards',
			'openFirstItem'        => false,
			'showQuestionNumbers'  => false,
			'colorPreset'          => 'blue',
			'density'              => 'comfortable',
			'sharpCorners'         => false,
		);
	}

	/**
	 * Attribute schema for register_block_type.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	public static function attribute_schema() {
		return array(
			'schemaVersion'       => array(
				'type'    => 'number',
				'default' => self::SCHEMA_VERSION,
			),
			'heading'             => array(
				'type'    => 'string',
				'default' => '',
			),
			'intro'               => array(
				'type'    => 'string',
				'default' => '',
			),
			'items'               => array(
				'type'    => 'array',
				'default' => array(),
			),
			'displayMode'         => array(
				'type'    => 'string',
				'default' => 'accordion',
			),
			'stylePreset'         => array(
				'type'    => 'string',
				'default' => 'accordion-cards',
			),
			'openFirstItem'       => array(
				'type'    => 'boolean',
				'default' => false,
			),
			'showQuestionNumbers' => array(
				'type'    => 'boolean',
				'default' => false,
			),
			'colorPreset'         => array(
				'type'    => 'string',
				'default' => 'blue',
			),
			'density'             => array(
				'type'    => 'string',
				'default' => 'comfortable',
			),
			'sharpCorners'        => array(
				'type'    => 'boolean',
				'default' => false,
			),
		);
	}

	/**
	 * Allowed HTML tags for FAQ answers (visible rich text).
	 *
	 * @return array<string,array<string,bool>>
	 */
	public static function answer_kses_allowed() {
		return array(
			'p'      => array(),
			'br'     => array(),
			'strong' => array(),
			'b'      => array(),
			'em'     => array(),
			'i'      => array(),
			'ul'     => array(),
			'ol'     => array(),
			'li'     => array(),
			'a'      => array(
				'href'   => true,
				'title'  => true,
				'rel'    => true,
				'target' => true,
			),
		);
	}

	/**
	 * Sanitize a raw attributes bag.
	 *
	 * @param mixed $raw     Raw attributes.
	 * @param array $context Optional limit overrides / flags.
	 * @return array{attributes:array<string,mixed>,errors:string[],stripped:string[]}
	 */
	public static function sanitize( $raw, array $context = array() ) {
		$defaults = self::defaults();
		$errors   = array();
		$stripped = array();
		$limits   = FaqBlockLimits::all( $context );

		if ( ! is_array( $raw ) ) {
			$errors[] = 'attributes_not_array';
			return array(
				'attributes' => $defaults,
				'errors'     => $errors,
				'stripped'   => $stripped,
			);
		}

		$encoded = wp_json_encode( $raw );
		if ( is_string( $encoded ) && strlen( $encoded ) > $limits['max_payload_bytes'] ) {
			$errors[] = 'payload_too_large';
			return array(
				'attributes' => $defaults,
				'errors'     => $errors,
				'stripped'   => $stripped,
			);
		}

		$allowed_keys = array_keys( $defaults );
		foreach ( array_keys( $raw ) as $key ) {
			if ( ! in_array( (string) $key, $allowed_keys, true ) ) {
				$stripped[] = (string) $key;
			}
		}

		$out = $defaults;

		$version = isset( $raw['schemaVersion'] ) ? (int) $raw['schemaVersion'] : self::SCHEMA_VERSION;
		if ( self::SCHEMA_VERSION !== $version ) {
			$errors[] = 'unsupported_schema_version';
			$version  = self::SCHEMA_VERSION;
		}
		$out['schemaVersion'] = $version;

		$out['heading'] = self::sanitize_plain_text(
			isset( $raw['heading'] ) ? $raw['heading'] : '',
			$limits['max_heading_length']
		);
		$out['intro'] = self::sanitize_plain_text(
			isset( $raw['intro'] ) ? $raw['intro'] : '',
			$limits['max_intro_length']
		);

		$out['displayMode'] = self::sanitize_enum(
			isset( $raw['displayMode'] ) ? $raw['displayMode'] : '',
			FaqBlockLimits::display_modes(),
			'accordion',
			$errors,
			'displayMode'
		);
		$out['stylePreset'] = self::sanitize_enum(
			isset( $raw['stylePreset'] ) ? $raw['stylePreset'] : '',
			FaqBlockLimits::style_presets(),
			'accordion-cards',
			$errors,
			'stylePreset'
		);
		$out['colorPreset'] = self::sanitize_enum(
			isset( $raw['colorPreset'] ) ? $raw['colorPreset'] : '',
			FaqBlockLimits::color_presets(),
			'blue',
			$errors,
			'colorPreset'
		);
		$out['density'] = self::sanitize_enum(
			isset( $raw['density'] ) ? $raw['density'] : '',
			FaqBlockLimits::densities(),
			'comfortable',
			$errors,
			'density'
		);

		$out['openFirstItem']      = ! empty( $raw['openFirstItem'] );
		$out['showQuestionNumbers'] = ! empty( $raw['showQuestionNumbers'] );
		$out['sharpCorners']       = ! empty( $raw['sharpCorners'] );

		$items_raw = isset( $raw['items'] ) ? $raw['items'] : array();
		if ( ! is_array( $items_raw ) ) {
			$errors[]  = 'items_not_array';
			$items_raw = array();
		}

		$items = array();
		foreach ( $items_raw as $item ) {
			if ( count( $items ) >= $limits['max_items'] ) {
				$errors[] = 'too_many_items';
				break;
			}
			$clean = self::sanitize_item( $item, $limits, $errors );
			if ( null !== $clean ) {
				$items[] = $clean;
			}
		}
		$out['items'] = $items;

		return array(
			'attributes' => $out,
			'errors'     => array_values( array_unique( $errors ) ),
			'stripped'   => array_values( array_unique( $stripped ) ),
		);
	}

	/**
	 * Items that have both a question and an answer (emission candidates).
	 *
	 * @param array $attributes Sanitized attributes.
	 * @return array<int,array{id:string,question:string,answer:string,answer_plain:string}>
	 */
	public static function get_complete_items( array $attributes ) {
		$items = isset( $attributes['items'] ) && is_array( $attributes['items'] ) ? $attributes['items'] : array();
		$out   = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$question = isset( $item['question'] ) ? trim( (string) $item['question'] ) : '';
			$answer   = isset( $item['answer'] ) ? (string) $item['answer'] : '';
			$plain    = isset( $item['answer_plain'] ) ? (string) $item['answer_plain'] : self::normalize_plain_answer( $answer );
			if ( '' === $question || '' === $plain ) {
				continue;
			}
			$out[] = array(
				'id'           => (string) ( $item['id'] ?? '' ),
				'question'     => $question,
				'answer'       => $answer,
				'answer_plain' => $plain,
			);
		}
		return $out;
	}

	/**
	 * Plain-text schema normalization for a question.
	 *
	 * @param string $question Question text.
	 * @return string
	 */
	public static function normalize_question_for_schema( $question ) {
		return SchemaSourceDescriptor::normalize_faq_question( $question );
	}

	/**
	 * Plain-text schema normalization for an answer.
	 *
	 * @param string $answer Answer HTML or text.
	 * @return string
	 */
	public static function normalize_plain_answer( $answer ) {
		if ( ! is_string( $answer ) ) {
			return '';
		}
		$answer = self::decode_block_attr_string( $answer );
		$text   = wp_strip_all_tags( $answer );
		$text   = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text   = preg_replace( '/\s+/u', ' ', $text );
		return is_string( $text ) ? trim( $text ) : '';
	}

	/**
	 * Repair block-attribute strings that lost JSON_HEX_TAG backslashes.
	 *
	 * serialize_block() encodes `<` as `\u003c`. After some slash round-trips the
	 * leading backslash is stripped (`u003c`), which breaks tag stripping and
	 * leaves garbage in schema plain text. Only rewrites when no real `<` remains.
	 *
	 * @param mixed $value Raw attribute value.
	 * @return mixed
	 */
	public static function decode_block_attr_string( $value ) {
		if ( ! is_string( $value ) || '' === $value ) {
			return $value;
		}
		if ( false !== strpos( $value, '<' ) ) {
			return $value;
		}
		if ( ! preg_match( '/u003[ce]/i', $value ) && false === stripos( $value, 'u0022' ) && false === stripos( $value, 'u0026' ) ) {
			return $value;
		}

		// Restore common JSON_HEX_* sequences, then decode any remaining \uXXXX forms.
		$repaired = str_replace(
			array( '\\u003c', '\\u003e', '\\u0022', '\\u0026', 'u003c', 'u003e', 'u0022', 'u0026', 'u003C', 'u003E' ),
			array( '<', '>', '"', '&', '<', '>', '"', '&', '<', '>' ),
			$value
		);

		return $repaired;
	}

	/**
	 * Sanitize one FAQ item.
	 *
	 * @param mixed $item   Raw item.
	 * @param array $limits Limits.
	 * @param array $errors Error bag.
	 * @return array{id:string,question:string,answer:string,answer_plain:string}|null
	 */
	private static function sanitize_item( $item, array $limits, array &$errors ) {
		if ( ! is_array( $item ) ) {
			$errors[] = 'item_not_object';
			return null;
		}

		$id = isset( $item['id'] ) ? (string) $item['id'] : '';
		if ( ! SchemaInstanceStorage::is_valid_uuid( $id ) ) {
			$id = SchemaInstanceStorage::generate_instance_id();
		}

		$question = self::sanitize_plain_text(
			isset( $item['question'] ) ? $item['question'] : '',
			$limits['max_question_length']
		);
		$answer = self::sanitize_answer_html(
			isset( $item['answer'] ) ? $item['answer'] : '',
			$limits['max_answer_length']
		);

		return array(
			'id'           => strtolower( $id ),
			'question'     => $question,
			'answer'       => $answer,
			'answer_plain' => self::normalize_plain_answer( $answer ),
		);
	}

	/**
	 * Strip tags and truncate plain text.
	 *
	 * @param mixed $value  Raw.
	 * @param int   $max    Max length.
	 * @return string
	 */
	private static function sanitize_plain_text( $value, $max ) {
		if ( is_array( $value ) || is_object( $value ) ) {
			return '';
		}
		$value = self::decode_block_attr_string( (string) $value );
		$text  = wp_strip_all_tags( (string) $value );
		$text  = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text  = preg_replace( '/\s+/u', ' ', $text );
		$text  = is_string( $text ) ? trim( $text ) : '';
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $text, 0, (int) $max );
		}
		return substr( $text, 0, (int) $max );
	}

	/**
	 * Sanitize answer rich text and enforce length on plain text.
	 *
	 * @param mixed $value Raw answer.
	 * @param int   $max   Max plain length.
	 * @return string
	 */
	private static function sanitize_answer_html( $value, $max ) {
		if ( is_array( $value ) || is_object( $value ) ) {
			return '';
		}
		$value = self::decode_block_attr_string( (string) $value );
		// Remove script/style elements (including their text) before allowlisting.
		$value = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $value );
		$value = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', '', is_string( $value ) ? $value : '' );
		$value = preg_replace( '/<\/?script\b[^>]*>/i', '', is_string( $value ) ? $value : '' );
		$value = is_string( $value ) ? $value : '';
		$html  = wp_kses( $value, self::answer_kses_allowed() );
		$html  = self::scrub_unsafe_anchors( $html );

		$plain = self::normalize_plain_answer( $html );
		if ( function_exists( 'mb_strlen' ) && mb_strlen( $plain ) > (int) $max ) {
			$plain = mb_substr( $plain, 0, (int) $max );
			return esc_html( $plain );
		}
		if ( strlen( $plain ) > (int) $max ) {
			$plain = substr( $plain, 0, (int) $max );
			return esc_html( $plain );
		}

		return $html;
	}

	/**
	 * Drop javascript:/data: hrefs that survive kses in edge cases.
	 *
	 * @param string $html HTML.
	 * @return string
	 */
	private static function scrub_unsafe_anchors( $html ) {
		if ( '' === $html || false === stripos( $html, '<a ' ) ) {
			return $html;
		}

		return (string) preg_replace_callback(
			'/<a\b([^>]*)>/i',
			static function ( $matches ) {
				$attrs = $matches[1];
				if ( preg_match( '/href\s*=\s*([\'"])(.*?)\1/i', $attrs, $href ) ) {
					$url = trim( html_entity_decode( $href[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
					$lower = strtolower( $url );
					if ( 0 === strpos( $lower, 'javascript:' ) || 0 === strpos( $lower, 'data:' ) || 0 === strpos( $lower, 'vbscript:' ) ) {
						$attrs = preg_replace( '/\s*href\s*=\s*([\'"]).*?\1/i', '', $attrs );
					}
				}
				return '<a' . $attrs . '>';
			},
			$html
		);
	}

	/**
	 * Enum allowlist with default fallback.
	 *
	 * @param mixed    $value    Raw.
	 * @param string[] $allowed  Allowlist.
	 * @param string   $default  Default.
	 * @param string[] $errors   Error bag.
	 * @param string   $key      Attribute key.
	 * @return string
	 */
	private static function sanitize_enum( $value, array $allowed, $default, array &$errors, $key ) {
		$value = is_scalar( $value ) ? (string) $value : '';
		if ( in_array( $value, $allowed, true ) ) {
			return $value;
		}
		if ( '' !== $value ) {
			$errors[] = 'invalid_enum_' . $key;
		}
		return $default;
	}
}
