<?php
/**
 * Definition-driven sanitizer for V2 guided schema instance fields.
 *
 * Server field definitions (Schema type classes) are authoritative. Unknown
 * keys, oversized payloads, and hostile nested structures are stripped.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

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

/**
 * Sanitize guided instance field payloads.
 */
class SchemaInstanceFieldSanitizer {

	/**
	 * Soft default max string length (filterable).
	 */
	const DEFAULT_MAX_STRING_LENGTH = 5000;

	/**
	 * Hard max string length.
	 */
	const HARD_MAX_STRING_LENGTH = 20000;

	/**
	 * Soft default max list items (filterable).
	 */
	const DEFAULT_MAX_LIST_ITEMS = 50;

	/**
	 * Hard max list items.
	 */
	const HARD_MAX_LIST_ITEMS = 100;

	/**
	 * Soft default max nesting depth (filterable).
	 */
	const DEFAULT_MAX_DEPTH = 6;

	/**
	 * Hard max nesting depth.
	 */
	const HARD_MAX_DEPTH = 8;

	/**
	 * Soft default max encoded field-payload bytes (filterable).
	 */
	const DEFAULT_MAX_PAYLOAD_BYTES = 131072;

	/**
	 * Hard max encoded field-payload bytes.
	 */
	const HARD_MAX_PAYLOAD_BYTES = 262144;

	/**
	 * Allowed scalar field definition types.
	 *
	 * @var string[]
	 */
	const SCALAR_TYPES = array(
		'Text',
		'Title',
		'Textarea',
		'URL',
		'Url',
		'Number',
		'Enum',
		'DateTime',
		'Image',
		'Media',
		'Hidden',
	);

	/**
	 * Sanitize fields for a guided schema type using server definitions.
	 *
	 * @param string $type    Schema @type (e.g. Event).
	 * @param mixed  $fields  Raw client/migration fields.
	 * @param array  $context Optional: depth, max_* overrides for tests.
	 * @return array{
	 *   ok:bool,
	 *   fields:array<string,mixed>,
	 *   errors:string[],
	 *   stripped:string[]
	 * }
	 */
	public static function sanitize( $type, $fields, $context = array() ) {
		$type   = is_string( $type ) ? $type : '';
		$errors = array();
		$stripped = array();

		if ( '' === $type || ! self::is_guided_or_manual_product_type( $type ) ) {
			return self::result( false, array(), array( 'unknown_type' ), array() );
		}

		if ( null === $fields ) {
			$fields = array();
		}
		if ( ! is_array( $fields ) ) {
			return self::result( false, array(), array( 'malformed_fields' ), array() );
		}

		// Accept legacy definition-shaped lists ({id,std}) from migrations.
		if ( class_exists( SchemaMigration::class ) ) {
			$fields = SchemaMigration::extract_field_values( $fields );
		}

		$bytes = strlen( (string) wp_json_encode( $fields ) );
		$max_bytes = self::limit( 'max_payload_bytes', self::DEFAULT_MAX_PAYLOAD_BYTES, self::HARD_MAX_PAYLOAD_BYTES, $context );
		if ( $bytes > $max_bytes ) {
			return self::result( false, array(), array( 'payload_too_large' ), array() );
		}

		$definitions = self::get_field_definitions( $type );
		if ( empty( $definitions ) ) {
			return self::result( false, array(), array( 'missing_field_definitions' ), array() );
		}

		$max_depth = self::limit( 'max_depth', self::DEFAULT_MAX_DEPTH, self::HARD_MAX_DEPTH, $context );
		$clean     = self::sanitize_by_definitions( $fields, $definitions, 0, $max_depth, $context, $errors, $stripped );

		if ( ! empty( $errors ) && in_array( 'max_depth_exceeded', $errors, true ) ) {
			return self::result( false, array(), $errors, $stripped );
		}

		return self::result( true, $clean, $errors, $stripped );
	}

	/**
	 * Whether the type may be stored as a guided / manual instance.
	 *
	 * @param string $type Type name.
	 * @return bool
	 */
	public static function is_guided_or_manual_product_type( $type ) {
		$entry = SchemaTypePolicyRegistry::get_entry( $type );
		if ( ! is_array( $entry ) ) {
			return false;
		}
		$method = (string) ( $entry['editor_method'] ?? '' );
		if ( 'guided_form' === $method ) {
			return true;
		}
		// Manual Product fallback shares the guided instance store.
		return 'Product' === $type;
	}

	/**
	 * Field definitions for a type (server-authoritative).
	 *
	 * @param string $type Schema type.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_field_definitions( $type ) {
		$types = SchemaUtils::get_schema_types();
		$class = $types[ $type ] ?? null;
		if ( ! $class || ! class_exists( $class ) ) {
			return array();
		}
		$instance = new $class();
		$fields   = $instance->get();
		return is_array( $fields ) ? array_values( $fields ) : array();
	}

	/**
	 * Recursively sanitize values against definitions.
	 *
	 * @param array                        $values      Raw values.
	 * @param array<int, array<string,mixed>> $definitions Field defs.
	 * @param int                          $depth       Current depth.
	 * @param int                          $max_depth   Max depth.
	 * @param array                        $context     Limits context.
	 * @param string[]                     $errors      Error collector.
	 * @param string[]                     $stripped    Stripped key collector.
	 * @return array<string,mixed>
	 */
	private static function sanitize_by_definitions( array $values, array $definitions, $depth, $max_depth, array $context, array &$errors, array &$stripped ) {
		if ( $depth > $max_depth ) {
			$errors[] = 'max_depth_exceeded';
			return array();
		}

		$by_id = array();
		foreach ( $definitions as $field ) {
			if ( ! is_array( $field ) || empty( $field['id'] ) ) {
				continue;
			}
			$by_id[ (string) $field['id'] ] = $field;
		}

		foreach ( array_keys( $values ) as $raw_key ) {
			$key = sanitize_text_field( (string) $raw_key );
			if ( ! isset( $by_id[ $key ] ) ) {
				$stripped[] = $key;
			}
		}

		$clean = array();
		foreach ( $by_id as $field_id => $field ) {
			$field_type = (string) ( $field['type'] ?? 'Text' );

			if ( ! array_key_exists( $field_id, $values ) ) {
				// Apply Hidden defaults (e.g. @id, nested @type) so guided saves emit stable IDs/types.
				if ( 'Hidden' === $field_type && isset( $field['std'] ) && '' !== (string) $field['std'] ) {
					$clean[ $field_id ] = self::sanitize_hidden( $field_id, (string) $field['std'], $field, $context );
				}
				continue;
			}
			$raw_value = $values[ $field_id ];

			if ( 'Group' === $field_type ) {
				$nested_defs = isset( $field['fields'] ) && is_array( $field['fields'] ) ? $field['fields'] : array();
				if ( ! empty( $field['cloneable'] ) ) {
					$clean[ $field_id ] = self::sanitize_cloneable_group( $raw_value, $nested_defs, $depth, $max_depth, $context, $errors, $stripped );
				} else {
					if ( ! is_array( $raw_value ) ) {
						$stripped[] = $field_id;
						continue;
					}
					// List-shaped non-cloneable groups (e.g. HowTo steps stored as arrays).
					if ( self::is_list( $raw_value ) ) {
						$clean[ $field_id ] = self::sanitize_cloneable_group( $raw_value, $nested_defs, $depth, $max_depth, $context, $errors, $stripped );
					} else {
						$clean[ $field_id ] = self::sanitize_by_definitions( $raw_value, $nested_defs, $depth + 1, $max_depth, $context, $errors, $stripped );
					}
				}
				continue;
			}

			$sanitized = self::sanitize_scalar( $field_type, $raw_value, $field, $context, $errors, $stripped, $field_id );
			if ( null !== $sanitized ) {
				$clean[ $field_id ] = $sanitized;
			}
		}

		return $clean;
	}

	/**
	 * Sanitize a cloneable / list group.
	 *
	 * @param mixed                        $raw_value   Raw list.
	 * @param array<int, array<string,mixed>> $nested_defs Nested defs.
	 * @param int                          $depth       Depth.
	 * @param int                          $max_depth   Max depth.
	 * @param array                        $context     Context.
	 * @param string[]                     $errors      Errors.
	 * @param string[]                     $stripped    Stripped.
	 * @return array<int, array<string,mixed>>
	 */
	private static function sanitize_cloneable_group( $raw_value, array $nested_defs, $depth, $max_depth, array $context, array &$errors, array &$stripped ) {
		if ( ! is_array( $raw_value ) ) {
			return array();
		}

		$max_items = self::limit( 'max_list_items', self::DEFAULT_MAX_LIST_ITEMS, self::HARD_MAX_LIST_ITEMS, $context );
		$items     = self::is_list( $raw_value ) ? $raw_value : array( $raw_value );
		if ( count( $items ) > $max_items ) {
			$errors[] = 'list_too_large';
			$items    = array_slice( $items, 0, $max_items );
		}

		$clean = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$clean[] = self::sanitize_by_definitions( $item, $nested_defs, $depth + 1, $max_depth, $context, $errors, $stripped );
		}
		return $clean;
	}

	/**
	 * Sanitize a scalar field value.
	 *
	 * @param string               $field_type Field type.
	 * @param mixed                $value      Raw value.
	 * @param array<string,mixed>  $field      Field definition.
	 * @param array                $context    Context.
	 * @param string[]             $errors     Errors.
	 * @param string[]             $stripped   Stripped.
	 * @param string               $field_id   Field id.
	 * @return mixed|null Null when the value should be dropped.
	 */
	private static function sanitize_scalar( $field_type, $value, array $field, array $context, array &$errors, array &$stripped, $field_id ) {
		if ( is_array( $value ) ) {
			// Allow simple string lists under Text fields (e.g. recipeIngredient).
			if ( in_array( $field_type, array( 'Text', 'Title', 'Textarea' ), true ) && self::is_list( $value ) ) {
				$max_items = self::limit( 'max_list_items', self::DEFAULT_MAX_LIST_ITEMS, self::HARD_MAX_LIST_ITEMS, $context );
				$out       = array();
				foreach ( array_slice( $value, 0, $max_items ) as $item ) {
					if ( is_scalar( $item ) || null === $item ) {
						$out[] = self::sanitize_text_value( (string) $item, $context );
					}
				}
				return $out;
			}
			$stripped[] = $field_id;
			return null;
		}

		if ( is_object( $value ) ) {
			$stripped[] = $field_id;
			return null;
		}

		switch ( $field_type ) {
			case 'Number':
				return self::sanitize_number( $value, $errors, $field_id );

			case 'URL':
			case 'Url':
				return self::sanitize_url_value( (string) $value );

			case 'Image':
			case 'Media':
				return self::sanitize_image_value( $value );

			case 'DateTime':
				$normalized = SchemaSanitizer::normalize_datetime_value( self::sanitize_text_value( (string) $value, $context ) );
				return is_string( $normalized ) ? $normalized : '';

			case 'Enum':
				return self::sanitize_enum( (string) $value, $field, $stripped, $field_id );

			case 'Hidden':
				return self::sanitize_hidden( $field_id, (string) $value, $field, $context );

			case 'Textarea':
				return self::sanitize_textarea_value( (string) $value, $context );

			case 'Text':
			case 'Title':
			default:
				// URL-ish Text fields still get URL sanitization when the id is url.
				if ( 'url' === strtolower( (string) $field_id ) || 'ticketurl' === strtolower( str_replace( array( '_', '-' ), '', (string) $field_id ) ) ) {
					return self::sanitize_url_value( (string) $value );
				}
				return self::sanitize_text_value( (string) $value, $context );
		}
	}

	/**
	 * Sanitize plain text (strips tags / control chars, enforces length).
	 *
	 * @param string $value   Raw.
	 * @param array  $context Context.
	 * @return string
	 */
	private static function sanitize_text_value( $value, array $context ) {
		$value = wp_check_invalid_utf8( (string) $value );
		$value = wp_strip_all_tags( $value );
		$value = sanitize_text_field( $value );
		$max   = self::limit( 'max_string_length', self::DEFAULT_MAX_STRING_LENGTH, self::HARD_MAX_STRING_LENGTH, $context );
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $value, 0, $max );
		}
		return substr( $value, 0, $max );
	}

	/**
	 * Sanitize textarea (preserve newlines, strip tags).
	 *
	 * @param string $value   Raw.
	 * @param array  $context Context.
	 * @return string
	 */
	private static function sanitize_textarea_value( $value, array $context ) {
		$value = wp_check_invalid_utf8( (string) $value );
		$value = wp_strip_all_tags( $value );
		$value = sanitize_textarea_field( $value );
		$max   = self::limit( 'max_string_length', self::DEFAULT_MAX_STRING_LENGTH, self::HARD_MAX_STRING_LENGTH, $context );
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $value, 0, $max );
		}
		return substr( $value, 0, $max );
	}

	/**
	 * Sanitize URL-like values; preserve %variable% tokens.
	 *
	 * @param string $value Raw.
	 * @return string
	 */
	private static function sanitize_url_value( $value ) {
		$value = trim( wp_check_invalid_utf8( (string) $value ) );
		if ( '' === $value ) {
			return '';
		}
		if ( false !== strpos( $value, '%' ) ) {
			return self::sanitize_text_value( $value, array() );
		}
		$clean = esc_url_raw( $value );
		return is_string( $clean ) ? $clean : '';
	}

	/**
	 * Sanitize Image/Media: attachment ID, URL, or %token%.
	 *
	 * @param mixed $value Raw.
	 * @return int|string
	 */
	private static function sanitize_image_value( $value ) {
		if ( is_int( $value ) || ( is_string( $value ) && ctype_digit( $value ) ) ) {
			return absint( $value );
		}
		$value = (string) $value;
		if ( false !== strpos( $value, '%' ) ) {
			return self::sanitize_text_value( $value, array() );
		}
		if ( preg_match( '#^https?://#i', $value ) || 0 === strpos( $value, '/' ) ) {
			return self::sanitize_url_value( $value );
		}
		return self::sanitize_text_value( $value, array() );
	}

	/**
	 * Sanitize numeric values with boundary checks.
	 *
	 * @param mixed    $value    Raw.
	 * @param string[] $errors   Errors.
	 * @param string   $field_id Field id.
	 * @return int|float|string|null
	 */
	private static function sanitize_number( $value, array &$errors, $field_id ) {
		if ( is_string( $value ) ) {
			$value = trim( $value );
			if ( '' === $value ) {
				return '';
			}
			if ( false !== strpos( $value, '%' ) ) {
				return self::sanitize_text_value( $value, array() );
			}
		}
		if ( ! is_numeric( $value ) ) {
			$errors[] = 'invalid_number:' . $field_id;
			return null;
		}
		$number = (float) $value;
		if ( $number > 1.0e15 || $number < -1.0e15 ) {
			$errors[] = 'number_out_of_bounds:' . $field_id;
			return null;
		}
		if ( (string) (int) $number === (string) $value || is_int( $value ) ) {
			return (int) $number;
		}
		return $number;
	}

	/**
	 * Sanitize enum against definition allowlist.
	 *
	 * @param string              $value    Raw.
	 * @param array<string,mixed> $field    Def.
	 * @param string[]            $stripped Stripped.
	 * @param string              $field_id Id.
	 * @return string|null
	 */
	private static function sanitize_enum( $value, array $field, array &$stripped, $field_id ) {
		$value   = self::sanitize_text_value( $value, array() );
		$options = array();
		if ( isset( $field['options'] ) && is_array( $field['options'] ) ) {
			foreach ( $field['options'] as $key => $label ) {
				$options[] = is_int( $key ) ? (string) $label : (string) $key;
			}
		}
		if ( empty( $options ) ) {
			return $value;
		}
		if ( ! in_array( $value, $options, true ) ) {
			$stripped[] = $field_id;
			return null;
		}
		return $value;
	}

	/**
	 * Sanitize Hidden fields (@type, @id, etc.).
	 *
	 * @param string              $field_id Id.
	 * @param string              $value    Raw.
	 * @param array<string,mixed> $field    Def.
	 * @param array               $context  Context.
	 * @return string
	 */
	private static function sanitize_hidden( $field_id, $value, array $field, array $context ) {
		$value = self::sanitize_text_value( $value, $context );
		if ( '@type' === $field_id ) {
			$std = isset( $field['std'] ) ? (string) $field['std'] : '';
			if ( '' !== $std ) {
				return sanitize_text_field( $std );
			}
			$allowed = SchemaSanitizer::get_allowed_types();
			return in_array( $value, $allowed, true ) ? $value : 'Thing';
		}
		return $value;
	}

	/**
	 * Whether an array is a list (0..n-1 keys).
	 *
	 * @param array $value Array.
	 * @return bool
	 */
	private static function is_list( array $value ) {
		if ( array() === $value ) {
			return true;
		}
		return array_keys( $value ) === range( 0, count( $value ) - 1 );
	}

	/**
	 * Resolve a filterable limit with a hard cap.
	 *
	 * @param string $key      Limit key.
	 * @param int    $default  Soft default.
	 * @param int    $hard_max Hard maximum.
	 * @param array  $context  Optional overrides.
	 * @return int
	 */
	private static function limit( $key, $default, $hard_max, array $context ) {
		if ( isset( $context[ $key ] ) ) {
			$value = (int) $context[ $key ];
		} else {
			/**
			 * Filters a Schema instance field sanitizer limit.
			 *
			 * @param int    $default Soft default.
			 * @param string $key     Limit key.
			 */
			$value = (int) apply_filters( 'seovaultseo_schema_instance_field_limit', $default, $key );
		}
		$value = ( 'max_depth' === $key ) ? max( 0, $value ) : max( 1, $value );
		return min( $value, (int) $hard_max );
	}

	/**
	 * Build a sanitizer result.
	 *
	 * @param bool     $ok       Success.
	 * @param array    $fields   Clean fields.
	 * @param string[] $errors   Errors.
	 * @param string[] $stripped Stripped keys.
	 * @return array{ok:bool,fields:array,errors:string[],stripped:string[]}
	 */
	private static function result( $ok, array $fields, array $errors, array $stripped ) {
		return array(
			'ok'       => (bool) $ok,
			'fields'   => $fields,
			'errors'   => array_values( array_unique( $errors ) ),
			'stripped' => array_values( array_unique( $stripped ) ),
		);
	}
}
