<?php
/**
 * Schema Sanitizer
 *
 * Sanitizes raw schema data (from AJAX, REST, or post meta) into a
 * safe, structured format suitable for storage and JSON-LD output.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

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

class SchemaSanitizer {

	/**
	 * Allowed schema @type values.
	 *
	 * @var array
	 */
	private static $allowed_types = array(
		'WebSite',
		'WebPage',
		'Organization',
		'BreadcrumbList',
		'Article',
		'SearchAction',
		'Person',
		'Product',
		'FAQPage',
		'HowTo',
		'Review',
		'Recipe',
		'Event',
		'Course',
		'SoftwareApplication',
		'JobPosting',
		'Thing',
		'ItemList',
		'Offer',
		'Question',
		'Answer',
		'Rating',
		'AggregateRating',
		'Place',
		'PostalAddress',
		'MonetaryAmount',
		'NutritionInformation',
		'Organization',
		'HowToStep',
		'HowToSupply',
		'HowToTool',
		'ListItem',
	);

	/**
	 * Keys that are always removed from schema output.
	 *
	 * @var array
	 */
	private static $internal_keys = array(
		'schema_name',
		'schemaDocs',
		'googleDocs',
		'_customFields',
		'isPrimary',
		'isCustom',
		'isTemplate',
	);

	/**
	 * Sanitize a single schema entry.
	 *
	 * @param array $schema Raw schema data.
	 * @return array Sanitized schema.
	 */
	public static function sanitize_schema( $schema ) {
		if ( ! is_array( $schema ) ) {
			return array();
		}

		$clean = array();

		$clean['title']   = sanitize_text_field( $schema['title'] ?? '' );
		$clean['type']    = self::sanitize_type( $schema['type'] ?? 'Thing' );
		$clean['enabled'] = array_key_exists( 'enabled', $schema )
			? (bool) $schema['enabled']
			: SchemaUtils::is_type_enabled_by_default( $clean['type'] );

		$clean['show_on'] = self::sanitize_show_on( $schema['show_on'] ?? array() );

		if ( isset( $schema['not_show_on'] ) ) {
			$clean['not_show_on'] = self::sanitize_show_on( $schema['not_show_on'] );
		}

		$clean['fields'] = self::sanitize_fields( $schema['fields'] ?? array() );
		$clean['fields'] = self::normalize_datetime_fields( $clean['fields'], $clean['type'] );

		if ( isset( $schema['metadata'] ) ) {
			$clean['metadata'] = self::sanitize_metadata( $schema['metadata'] );
		}

		return $clean;
	}

	/**
	 * Sanitize an array of schemas (id => schema).
	 *
	 * @param array $schemas Raw schemas.
	 * @return array Sanitized schemas.
	 */
	public static function sanitize_schemas( $schemas ) {
		if ( ! is_array( $schemas ) ) {
			return array();
		}

		$clean = array();
		foreach ( $schemas as $id => $schema ) {
			$sanitized = self::sanitize_schema( $schema );
			if ( ! empty( $sanitized ) ) {
				$clean[ sanitize_key( $id ) ] = $sanitized;
			}
		}
		return $clean;
	}

	/**
	 * Sanitize a schema @type value.
	 *
	 * @param string $type Raw type.
	 * @return string Allowed type or 'Thing' fallback.
	 */
	public static function sanitize_type( $type ) {
		$type = sanitize_text_field( $type );
		if ( in_array( $type, self::$allowed_types, true ) ) {
			return $type;
		}
		return 'Thing';
	}

	/**
	 * Sanitize show_on / not_show_on rules.
	 *
	 * @param array $show_on Raw show_on data.
	 * @return array Sanitized.
	 */
	public static function sanitize_show_on( $show_on ) {
		if ( ! is_array( $show_on ) ) {
			return array(
				'rules'        => array(),
				'specific'     => array(),
				'specificText' => array(),
			);
		}

		return array(
			'rules'        => array_map( 'sanitize_text_field', $show_on['rules'] ?? array() ),
			'specific'     => array_map( 'absint', $show_on['specific'] ?? array() ),
			'specificText' => array_map( 'sanitize_text_field', $show_on['specificText'] ?? array() ),
		);
	}

	/**
	 * Sanitize schema fields recursively.
	 *
	 * @param array $fields Raw fields.
	 * @return array Sanitized fields.
	 */
	public static function sanitize_fields( $fields ) {
		if ( ! is_array( $fields ) ) {
			return array();
		}

		$clean = array();
		foreach ( $fields as $key => $value ) {
			$safe_key = sanitize_text_field( $key );

			if ( in_array( $safe_key, self::$internal_keys, true ) ) {
				continue;
			}

			if ( is_array( $value ) ) {
				$clean[ $safe_key ] = self::sanitize_fields( $value );
			} else {
				$clean[ $safe_key ] = sanitize_text_field( $value );
			}
		}
		return $clean;
	}

	/**
	 * Normalize DateTime-typed fields for a schema type to ISO 8601 where appropriate.
	 *
	 * Date-only values (Y-m-d) are left unchanged. Values with a time component
	 * are normalized to DATE_ATOM. Dynamic variable placeholders are preserved.
	 *
	 * @param array  $fields      Field values (key => value).
	 * @param string $schema_type Schema @type (e.g. Event, Article).
	 * @return array
	 */
	public static function normalize_datetime_fields( $fields, $schema_type ) {
		if ( ! is_array( $fields ) || '' === (string) $schema_type ) {
			return is_array( $fields ) ? $fields : array();
		}

		$definitions = self::get_field_definitions_for_type( $schema_type );
		if ( empty( $definitions ) ) {
			return $fields;
		}

		return self::normalize_fields_by_definition( $fields, $definitions );
	}

	/**
	 * Normalize a date/time string for JSON-LD output.
	 *
	 * @param mixed $value Raw value.
	 * @return mixed Normalized string, or original value when not normalizable.
	 */
	public static function normalize_datetime_value( $value ) {
		if ( ! is_string( $value ) ) {
			return $value;
		}

		$value = trim( $value );

		if ( '' === $value || false !== strpos( $value, '%' ) ) {
			return $value;
		}

		// Date-only remains valid when no time is provided.
		if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
			return $value;
		}

		try {
			return ( new \DateTimeImmutable( $value, wp_timezone() ) )->format( DATE_ATOM );
		} catch ( \Exception $e ) {
			return $value;
		}
	}

	/**
	 * Convert a stored datetime value to an HTML datetime-local input value.
	 *
	 * @param mixed $value Stored value (ISO 8601, datetime-local, or date-only).
	 * @return string Value suitable for input[type=datetime-local], or empty string.
	 */
	public static function to_datetime_local_value( $value ) {
		if ( ! is_string( $value ) ) {
			return '';
		}

		$value = trim( $value );

		if ( '' === $value || false !== strpos( $value, '%' ) ) {
			return '';
		}

		if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
			return $value . 'T00:00';
		}

		try {
			$dt = new \DateTimeImmutable( $value, wp_timezone() );
			return $dt->setTimezone( wp_timezone() )->format( 'Y-m-d\TH:i' );
		} catch ( \Exception $e ) {
			return '';
		}
	}

	/**
	 * Whether a stored field value should use a datetime-local control.
	 *
	 * Variable placeholders stay on a text input so templates remain editable.
	 *
	 * @param mixed $value Field value.
	 * @return bool
	 */
	public static function is_datetime_local_candidate( $value ) {
		if ( ! is_string( $value ) ) {
			return true;
		}

		$value = trim( $value );
		return '' === $value || false === strpos( $value, '%' );
	}

	/**
	 * Get field definitions for a registered schema type.
	 *
	 * @param string $schema_type Schema type name.
	 * @return array<int, array<string, mixed>>
	 */
	private static function get_field_definitions_for_type( $schema_type ) {
		$types = SchemaUtils::get_schema_types();
		$class = $types[ $schema_type ] ?? null;

		if ( ! $class || ! class_exists( $class ) ) {
			return array();
		}

		$instance = new $class();
		$fields   = $instance->get();

		return is_array( $fields ) ? $fields : array();
	}

	/**
	 * Walk field definitions and normalize DateTime values in place.
	 *
	 * @param array                        $values            Current values.
	 * @param array<int, array<string, mixed>> $field_definitions Field defs.
	 * @return array
	 */
	private static function normalize_fields_by_definition( array $values, array $field_definitions ) {
		foreach ( $field_definitions as $field ) {
			$field_id = $field['id'] ?? '';

			if ( '' === $field_id || ! array_key_exists( $field_id, $values ) ) {
				continue;
			}

			$field_type = $field['type'] ?? 'Text';

			if ( 'DateTime' === $field_type ) {
				if ( is_array( $values[ $field_id ] ) ) {
					foreach ( $values[ $field_id ] as $key => $date_value ) {
						$values[ $field_id ][ $key ] = self::normalize_datetime_value( $date_value );
					}
				} else {
					$values[ $field_id ] = self::normalize_datetime_value( $values[ $field_id ] );
				}
				continue;
			}

			if ( 'Group' !== $field_type || empty( $field['fields'] ) || ! is_array( $values[ $field_id ] ) ) {
				continue;
			}

			if ( ! empty( $field['cloneable'] ) ) {
				foreach ( $values[ $field_id ] as $index => $group_item ) {
					if ( is_array( $group_item ) ) {
						$values[ $field_id ][ $index ] = self::normalize_fields_by_definition( $group_item, $field['fields'] );
					}
				}
				continue;
			}

			$values[ $field_id ] = self::normalize_fields_by_definition( $values[ $field_id ], $field['fields'] );
		}

		return $values;
	}

	/**
	 * Sanitize metadata block (display conditions, title).
	 *
	 * @param array $metadata Raw metadata.
	 * @return array Sanitized.
	 */
	public static function sanitize_metadata( $metadata ) {
		if ( ! is_array( $metadata ) ) {
			return array();
		}

		$clean = array(
			'title' => sanitize_text_field( $metadata['title'] ?? '' ),
		);

		if ( isset( $metadata['displayConditions'] ) && is_array( $metadata['displayConditions'] ) ) {
			$sanitized                  = DisplayConditionsSanitizer::sanitize( $metadata['displayConditions'] );
			$clean['displayConditions'] = $sanitized['conditions'];
		}

		return $clean;
	}

	/**
	 * Sanitize a raw JSON-LD string (e.g. from custom schema input).
	 *
	 * @param string $json Raw JSON string.
	 * @return array|null Decoded and sanitized array, or null if invalid.
	 */
	public static function sanitize_json_ld( $json ) {
		$decoded = json_decode( $json, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}
		return self::sanitize_fields( $decoded );
	}

	/**
	 * Get list of allowed types.
	 *
	 * @return array
	 */
	public static function get_allowed_types() {
		$filtered = apply_filters( 'seovaultseo_schema_allowed_types', self::$allowed_types );
		$sanitized = SchemaExtensionApi::sanitize_allowed_types( $filtered );
		// If the filter wiped everything, fall back to built-ins (never empty allow-list).
		return ! empty( $sanitized ) ? $sanitized : self::$allowed_types;
	}

	/**
	 * Get list of internal keys to strip.
	 *
	 * @return array
	 */
	public static function get_internal_keys() {
		return self::$internal_keys;
	}
}
