<?php
/**
 * Server-authoritative HowTo block attribute sanitizer.
 *
 * Visible how-to content contract + Section 8 readiness bridge.
 * HowTo JSON-LD emission is handled by HowToBlockCollector.
 *
 * @package SEOvaultSEO\Blocks
 */

namespace SEOvaultSEO\Blocks;

use SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator;
use SEOvaultSEO\Schema\Readiness\SchemaReadinessResult;
use SEOvaultSEO\Schema\SchemaInstanceStorage;

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

/**
 * Sanitizes and normalizes seovault/howto block attributes.
 */
class HowToBlockAttributeSanitizer {

	/**
	 * Supported schemaVersion values (block contract, not Schema.org).
	 */
	const SCHEMA_VERSION = 1;

	/**
	 * Default attributes for a new block.
	 *
	 * @return array<string,mixed>
	 */
	public static function defaults() {
		return array(
			'schemaVersion' => self::SCHEMA_VERSION,
			'name'          => '',
			'description'  => '',
			'steps'        => array(),
			'supplies'     => array(),
			'tools'        => array(),
			'totalTime'    => '',
			'estimatedCost' => array(
				'currency' => '',
				'value'    => '',
			),
			'displayMode'  => 'numbered',
			'stylePreset'  => 'clean',
			'colorPreset'  => 'blue',
			'density'      => 'comfortable',
		);
	}

	/**
	 * 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,
			),
			'name'          => array(
				'type'    => 'string',
				'default' => '',
			),
			'description'   => array(
				'type'    => 'string',
				'default' => '',
			),
			'steps'         => array(
				'type'    => 'array',
				'default' => array(),
			),
			'supplies'      => array(
				'type'    => 'array',
				'default' => array(),
			),
			'tools'         => array(
				'type'    => 'array',
				'default' => array(),
			),
			'totalTime'     => array(
				'type'    => 'string',
				'default' => '',
			),
			'estimatedCost' => array(
				'type'    => 'object',
				'default' => array(
					'currency' => '',
					'value'    => '',
				),
			),
			'displayMode'   => array(
				'type'    => 'string',
				'default' => 'numbered',
			),
			'stylePreset'   => array(
				'type'    => 'string',
				'default' => 'clean',
			),
			'colorPreset'   => array(
				'type'    => 'string',
				'default' => 'blue',
			),
			'density'       => array(
				'type'    => 'string',
				'default' => 'comfortable',
			),
		);
	}

	/**
	 * Allowed HTML for step text (visible rich text).
	 *
	 * @return array<string,array<string,bool>>
	 */
	public static function step_text_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   = HowToBlockLimits::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';
			// Migrate unknown/old versions to current contract.
			$version  = self::SCHEMA_VERSION;
		}
		$out['schemaVersion'] = $version;

		$out['name'] = self::sanitize_plain_text(
			isset( $raw['name'] ) ? $raw['name'] : '',
			$limits['max_name_length']
		);
		$out['description'] = self::sanitize_plain_text(
			isset( $raw['description'] ) ? $raw['description'] : '',
			$limits['max_description_length']
		);

		$out['displayMode'] = self::sanitize_enum(
			isset( $raw['displayMode'] ) ? $raw['displayMode'] : '',
			HowToBlockLimits::display_modes(),
			'numbered',
			$errors,
			'displayMode'
		);
		$out['stylePreset'] = self::sanitize_enum(
			isset( $raw['stylePreset'] ) ? $raw['stylePreset'] : '',
			HowToBlockLimits::style_presets(),
			'clean',
			$errors,
			'stylePreset'
		);
		$out['colorPreset'] = self::sanitize_enum(
			isset( $raw['colorPreset'] ) ? $raw['colorPreset'] : '',
			HowToBlockLimits::color_presets(),
			'blue',
			$errors,
			'colorPreset'
		);
		$out['density'] = self::sanitize_enum(
			isset( $raw['density'] ) ? $raw['density'] : '',
			HowToBlockLimits::densities(),
			'comfortable',
			$errors,
			'density'
		);

		$out['totalTime'] = self::sanitize_duration(
			isset( $raw['totalTime'] ) ? $raw['totalTime'] : '',
			$errors
		);
		$out['estimatedCost'] = self::sanitize_estimated_cost(
			isset( $raw['estimatedCost'] ) ? $raw['estimatedCost'] : array(),
			$errors
		);

		$steps_raw = isset( $raw['steps'] ) ? $raw['steps'] : array();
		if ( ! is_array( $steps_raw ) ) {
			$errors[]  = 'steps_not_array';
			$steps_raw = array();
		}
		$steps = array();
		foreach ( $steps_raw as $step ) {
			if ( count( $steps ) >= $limits['max_steps'] ) {
				$errors[] = 'too_many_steps';
				break;
			}
			$clean = self::sanitize_step( $step, $limits, $errors );
			if ( null !== $clean ) {
				$steps[] = $clean;
			}
		}
		$out['steps'] = $steps;

		$out['supplies'] = self::sanitize_string_list(
			isset( $raw['supplies'] ) ? $raw['supplies'] : array(),
			$limits['max_supplies'],
			$limits['max_supply_length'],
			'supplies',
			$errors
		);
		$out['tools'] = self::sanitize_string_list(
			isset( $raw['tools'] ) ? $raw['tools'] : array(),
			$limits['max_tools'],
			$limits['max_tool_length'],
			'tools',
			$errors
		);

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

	/**
	 * Steps that have a meaningful name or text (emission candidates).
	 * Preserves order. Incomplete steps remain in attributes for the editor.
	 *
	 * @param array $attributes Sanitized attributes.
	 * @return array<int,array{id:string,name:string,text:string,text_plain:string,imageId:int}>
	 */
	public static function get_complete_steps( array $attributes ) {
		$steps = isset( $attributes['steps'] ) && is_array( $attributes['steps'] ) ? $attributes['steps'] : array();
		$out   = array();
		foreach ( $steps as $step ) {
			if ( ! is_array( $step ) ) {
				continue;
			}
			$name  = isset( $step['name'] ) ? trim( (string) $step['name'] ) : '';
			$plain = isset( $step['text_plain'] )
				? trim( (string) $step['text_plain'] )
				: self::normalize_plain_text( isset( $step['text'] ) ? $step['text'] : '' );
			if ( '' === $name && '' === $plain ) {
				continue;
			}
			$out[] = array(
				'id'         => (string) ( $step['id'] ?? '' ),
				'name'       => $name,
				'text'       => isset( $step['text'] ) ? (string) $step['text'] : '',
				'text_plain' => $plain,
				'imageId'    => isset( $step['imageId'] ) ? (int) $step['imageId'] : 0,
			);
		}
		return $out;
	}

	/**
	 * Map sanitized block attributes to SchemaReadinessEvaluator HowTo fields.
	 *
	 * @param array $attributes Sanitized attributes.
	 * @return array<string,mixed>
	 */
	public static function build_readiness_fields( array $attributes ) {
		$fields = array(
			'name' => isset( $attributes['name'] ) ? (string) $attributes['name'] : '',
		);

		$description = isset( $attributes['description'] ) ? trim( (string) $attributes['description'] ) : '';
		if ( '' !== $description ) {
			$fields['description'] = $description;
		}

		$steps = array();
		foreach ( self::get_complete_steps( $attributes ) as $step ) {
			$entry = array( '@type' => 'HowToStep' );
			if ( '' !== $step['name'] ) {
				$entry['name'] = $step['name'];
			}
			if ( '' !== $step['text_plain'] ) {
				$entry['text'] = $step['text_plain'];
			}
			$steps[] = $entry;
		}
		$fields['step'] = $steps;

		$total_time = isset( $attributes['totalTime'] ) ? trim( (string) $attributes['totalTime'] ) : '';
		if ( '' !== $total_time ) {
			$fields['totalTime'] = $total_time;
		}

		$cost = isset( $attributes['estimatedCost'] ) && is_array( $attributes['estimatedCost'] )
			? $attributes['estimatedCost']
			: array();
		$currency = isset( $cost['currency'] ) ? trim( (string) $cost['currency'] ) : '';
		$value    = isset( $cost['value'] ) ? trim( (string) $cost['value'] ) : '';
		if ( '' !== $currency && '' !== $value ) {
			$fields['estimatedCost'] = array(
				'@type'    => 'MonetaryAmount',
				'currency' => $currency,
				'value'    => $value,
			);
		}

		$supplies = isset( $attributes['supplies'] ) && is_array( $attributes['supplies'] ) ? $attributes['supplies'] : array();
		if ( ! empty( $supplies ) ) {
			$fields['supply'] = array_map(
				static function ( $name ) {
					return array(
						'@type' => 'HowToSupply',
						'name'  => (string) $name,
					);
				},
				$supplies
			);
		}

		$tools = isset( $attributes['tools'] ) && is_array( $attributes['tools'] ) ? $attributes['tools'] : array();
		if ( ! empty( $tools ) ) {
			$fields['tool'] = array_map(
				static function ( $name ) {
					return array(
						'@type' => 'HowToTool',
						'name'  => (string) $name,
					);
				},
				$tools
			);
		}

		return $fields;
	}

	/**
	 * Evaluate Section 8 HowTo readiness from block attributes (no emission).
	 *
	 * @param array $attributes Raw or sanitized attributes.
	 * @return SchemaReadinessResult
	 */
	public static function evaluate_readiness( array $attributes ) {
		$sanitized = self::sanitize( $attributes );
		return SchemaReadinessEvaluator::evaluate(
			'HowTo',
			self::build_readiness_fields( $sanitized['attributes'] )
		);
	}

	/**
	 * Setup status for editor / picker.
	 *
	 * @param array $attributes Raw or sanitized attributes.
	 * @return string empty|incomplete|ready_for_schema
	 */
	public static function setup_status( array $attributes ) {
		$sanitized = self::sanitize( $attributes );
		$attrs     = $sanitized['attributes'];
		$name      = trim( (string) $attrs['name'] );
		$steps     = isset( $attrs['steps'] ) && is_array( $attrs['steps'] ) ? $attrs['steps'] : array();
		$has_any   = '' !== $name || ! empty( $steps ) || ! empty( $attrs['supplies'] ) || ! empty( $attrs['tools'] );
		if ( ! $has_any ) {
			return 'empty';
		}
		$result = SchemaReadinessEvaluator::evaluate( 'HowTo', self::build_readiness_fields( $attrs ) );
		return $result->is_ready() ? 'ready_for_schema' : 'incomplete';
	}

	/**
	 * Normalize rich/plain text to schema-safe plain string.
	 *
	 * @param mixed $value Raw.
	 * @return string
	 */
	public static function normalize_plain_text( $value ) {
		if ( ! is_string( $value ) ) {
			return '';
		}
		$value = self::decode_block_attr_string( $value );
		$text  = wp_strip_all_tags( $value );
		$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.
	 *
	 * @param mixed $value Raw attribute value.
	 * @return mixed
	 */
	public static function decode_block_attr_string( $value ) {
		return FaqBlockAttributeSanitizer::decode_block_attr_string( $value );
	}

	/**
	 * Sanitize one step.
	 *
	 * @param mixed $step   Raw step.
	 * @param array $limits Limits.
	 * @param array $errors Error bag.
	 * @return array{id:string,name:string,text:string,text_plain:string,imageId:int}|null
	 */
	private static function sanitize_step( $step, array $limits, array &$errors ) {
		if ( ! is_array( $step ) ) {
			$errors[] = 'step_not_object';
			return null;
		}

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

		$name = self::sanitize_plain_text(
			isset( $step['name'] ) ? $step['name'] : '',
			$limits['max_step_name_length']
		);
		$text = self::sanitize_step_html(
			isset( $step['text'] ) ? $step['text'] : '',
			$limits['max_step_text_length']
		);
		$image_id = self::sanitize_media_id(
			isset( $step['imageId'] ) ? $step['imageId'] : 0,
			$errors
		);

		return array(
			'id'         => strtolower( $id ),
			'name'       => $name,
			'text'       => $text,
			'text_plain' => self::normalize_plain_text( $text ),
			'imageId'    => $image_id,
		);
	}

	/**
	 * Sanitize supplies/tools string lists.
	 *
	 * @param mixed  $list    Raw list.
	 * @param int    $max     Max items.
	 * @param int    $max_len Max string length.
	 * @param string $label   Error label.
	 * @param array  $errors  Error bag.
	 * @return string[]
	 */
	private static function sanitize_string_list( $list, $max, $max_len, $label, array &$errors ) {
		if ( ! is_array( $list ) ) {
			$errors[] = $label . '_not_array';
			return array();
		}
		$out = array();
		foreach ( $list as $item ) {
			if ( count( $out ) >= $max ) {
				$errors[] = 'too_many_' . $label;
				break;
			}
			$text = self::sanitize_plain_text( $item, $max_len );
			if ( '' === $text ) {
				continue;
			}
			$out[] = $text;
		}
		return $out;
	}

	/**
	 * ISO-8601 duration (PnDTnHnMnS / PTnH… subset). Empty when invalid.
	 *
	 * @param mixed $value  Raw.
	 * @param array $errors Error bag.
	 * @return string
	 */
	private static function sanitize_duration( $value, array &$errors ) {
		$text = self::sanitize_plain_text( $value, 40 );
		if ( '' === $text ) {
			return '';
		}
		// Accept Schema.org duration forms used by HowTo (e.g. PT30M, PT1H30M, P1DT2H).
		if ( ! preg_match( '/^P(?!$)(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/i', $text ) ) {
			$errors[] = 'invalid_duration';
			return '';
		}
		return strtoupper( $text );
	}

	/**
	 * Sanitize estimatedCost as MonetaryAmount-shaped object.
	 *
	 * @param mixed $raw    Raw cost.
	 * @param array $errors Error bag.
	 * @return array{currency:string,value:string}
	 */
	private static function sanitize_estimated_cost( $raw, array &$errors ) {
		$empty = array(
			'currency' => '',
			'value'    => '',
		);
		if ( ! is_array( $raw ) ) {
			if ( '' !== (string) $raw && '0' !== (string) $raw ) {
				$errors[] = 'estimated_cost_not_object';
			}
			return $empty;
		}

		$currency = self::sanitize_plain_text( isset( $raw['currency'] ) ? $raw['currency'] : '', 3 );
		$currency = strtoupper( $currency );
		if ( '' !== $currency && ! preg_match( '/^[A-Z]{3}$/', $currency ) ) {
			$errors[]  = 'invalid_currency';
			$currency  = '';
		}

		$value_raw = isset( $raw['value'] ) ? $raw['value'] : '';
		$value     = '';
		if ( is_numeric( $value_raw ) || ( is_string( $value_raw ) && is_numeric( trim( $value_raw ) ) ) ) {
			$num = (float) $value_raw;
			if ( $num < 0 ) {
				$errors[] = 'invalid_cost_value';
			} else {
				// Keep compact numeric string without scientific notation noise.
				$value = rtrim( rtrim( sprintf( '%.4F', $num ), '0' ), '.' );
				if ( '' === $value ) {
					$value = '0';
				}
			}
		} elseif ( '' !== trim( (string) $value_raw ) ) {
			$errors[] = 'invalid_cost_value';
		}

		if ( ( '' !== $currency && '' === $value ) || ( '' === $currency && '' !== $value ) ) {
			$errors[] = 'incomplete_estimated_cost';
			return $empty;
		}

		return array(
			'currency' => $currency,
			'value'    => $value,
		);
	}

	/**
	 * Positive attachment ID only; zero when invalid.
	 *
	 * @param mixed $value  Raw ID.
	 * @param array $errors Error bag.
	 * @return int
	 */
	private static function sanitize_media_id( $value, array &$errors ) {
		if ( null === $value || '' === $value || false === $value ) {
			return 0;
		}
		if ( ! is_numeric( $value ) ) {
			$errors[] = 'invalid_media_id';
			return 0;
		}
		$id = (int) $value;
		if ( $id < 0 ) {
			$errors[] = 'invalid_media_id';
			return 0;
		}
		return $id;
	}

	/**
	 * 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 step HTML with allowlist + plain length gate.
	 *
	 * @param mixed $value Raw HTML.
	 * @param int   $max   Max plain length.
	 * @return string
	 */
	private static function sanitize_step_html( $value, $max ) {
		if ( is_array( $value ) || is_object( $value ) ) {
			return '';
		}
		$value = self::decode_block_attr_string( (string) $value );
		$value = preg_replace( '/<script\b[^>]*>[\s\S]*?<\/script>/i', '', $value );
		$value = preg_replace( '/<style\b[^>]*>[\s\S]*?<\/style>/i', '', $value );
		$value = is_string( $value ) ? $value : '';
		$html  = wp_kses( $value, self::step_text_kses_allowed() );
		$plain = self::normalize_plain_text( $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;
	}

	/**
	 * Enum allowlist helper.
	 *
	 * @param mixed    $value    Raw.
	 * @param string[] $allowed  Allowed.
	 * @param string   $fallback Fallback.
	 * @param array    $errors   Error bag.
	 * @param string   $field    Field name.
	 * @return string
	 */
	private static function sanitize_enum( $value, array $allowed, $fallback, array &$errors, $field ) {
		$text = is_string( $value ) ? trim( $value ) : '';
		if ( '' === $text ) {
			return $fallback;
		}
		if ( ! in_array( $text, $allowed, true ) ) {
			$errors[] = 'invalid_enum_' . $field;
			return $fallback;
		}
		return $text;
	}
}
