<?php
/**
 * @package ACF
 * @author  WP Engine
 *
 * © 2026 Advanced Custom Fields (ACF®). All rights reserved.
 * "ACF" is a trademark of WP Engine.
 * Licensed under the GNU General Public License v2 or later.
 * https://www.gnu.org/licenses/gpl-2.0.html
 */

namespace ACF\AI\GEO;

/**
 * Class Schema
 *
 * Provides utilities for working with schema.org types and properties
 * using pre-generated schema data from SchemaData.php
 */
class Schema {

	/**
	 * Get priority schema types for common use cases
	 *
	 * Returns an array of commonly used Schema.org types that should be
	 * displayed first in selection dropdowns.
	 *
	 * @since 6.8.0
	 *
	 * @return array Array of priority type names.
	 */
	public static function get_priority_types() {
		$priority_types = array(
			'Thing',
			'Article',
			'BlogPosting',
			'NewsArticle',
			'Recipe',
			'Product',
			'Event',
			'HowTo',
			'FAQPage',
			'Person',
			'Organization',
			'LocalBusiness',
			'Place',
			'WebPage',
		);

		/**
		 * Filter the priority Schema.org types
		 *
		 * Allows developers to customize which types appear first in selection lists.
		 *
		 * @param array $priority_types Array of priority type names.
		 */
		return apply_filters( 'acf/schema/schema_priority_types', $priority_types );
	}

	/**
	 * Get all parent types for a given type
	 *
	 * @since 6.8.0
	 *
	 * @param string $type The schema.org type name
	 * @return array Array of parent type names in order from direct parent to root
	 */
	private static function get_type_parents( $type ) {
		$parents = array();
		$current = $type;

		while ( isset( SchemaData::$type_hierarchy[ $current ] ) ) {
			$parent    = SchemaData::$type_hierarchy[ $current ];
			$parents[] = $parent;
			$current   = $parent;
		}

		return $parents;
	}

	/**
	 * Check if type_a is a parent/ancestor of type_b
	 *
	 * @since 6.8.0
	 *
	 * @param string $type_a The potential parent type
	 * @param string $type_b The child type to check
	 * @return boolean True if type_a is an ancestor of type_b
	 */
	private static function is_parent_of( $type_a, $type_b ) {
		$parents = self::get_type_parents( $type_b );
		return in_array( $type_a, $parents, true );
	}

	/**
	 * Infer the minimal set of types needed for a set of properties
	 *
	 * Given a list of properties, returns the most general types that
	 * directly define those properties, avoiding redundant child types.
	 *
	 * For example:
	 * - ['prepTime', 'cookTime'] -> ['Recipe'] (most specific type with those properties)
	 * - ['headline'] -> ['CreativeWork'] (the base type that defines headline)
	 *
	 * @since 6.8.0
	 *
	 * @param array $properties Array of property names
	 * @return array Array of type names
	 */
	public static function infer_types_from_properties( $properties ) {
		if ( empty( $properties ) ) {
			return array();
		}

		// For each property, collect the types that directly define it
		$types_per_property = array();

		foreach ( $properties as $property ) {
			if ( ! isset( SchemaData::$property_domains[ $property ] ) ) {
				continue;
			}

			// These are the types that directly define this property
			$types_per_property[ $property ] = SchemaData::$property_domains[ $property ];
		}

		if ( empty( $types_per_property ) ) {
			return array();
		}

		// If we only have one property, return its direct types
		if ( count( $types_per_property ) === 1 ) {
			return array_values( reset( $types_per_property ) );
		}

		// Find types that can cover all properties (directly or through inheritance)
		$all_types   = array_unique( array_merge( ...array_values( $types_per_property ) ) );
		$valid_types = array();

		foreach ( $all_types as $type ) {
			$type_chain = array_merge( array( $type ), self::get_type_parents( $type ) );
			$covers_all = true;

			foreach ( $types_per_property as $property => $defining_types ) {
				// Check if this type or any of its parents define this property
				if ( empty( array_intersect( $type_chain, $defining_types ) ) ) {
					$covers_all = false;
					break;
				}
			}

			if ( $covers_all ) {
				$valid_types[] = $type;
			}
		}

		// If we found types that cover everything, remove redundant parents
		if ( ! empty( $valid_types ) ) {
			$minimal_types = array();
			foreach ( $valid_types as $type ) {
				$is_redundant = false;
				foreach ( $valid_types as $other_type ) {
					if ( $type !== $other_type && self::is_parent_of( $type, $other_type ) ) {
						// This type is a parent of another type in the list, so it's redundant
						$is_redundant = true;
						break;
					}
				}
				if ( ! $is_redundant ) {
					$minimal_types[] = $type;
				}
			}
			return array_values( $minimal_types );
		}

		// No single type covers all properties, need multiple types
		return self::find_minimal_type_set( $properties );
	}

	/**
	 * Find minimal set of types to cover all properties
	 *
	 * Uses a greedy algorithm to find the smallest set of types that
	 * collectively support all given properties.
	 *
	 * @since 6.8.0
	 *
	 * @param array $properties Array of property names
	 * @return array Array of type names
	 */
	private static function find_minimal_type_set( $properties ) {
		$uncovered_properties = $properties;
		$selected_types       = array();

		while ( ! empty( $uncovered_properties ) ) {
			$best_type     = null;
			$best_coverage = 0;

			// Find the type that covers the most uncovered properties
			foreach ( SchemaData::$type_hierarchy as $type => $parent ) {
				$coverage = 0;
				foreach ( $uncovered_properties as $property ) {
					if ( isset( SchemaData::$property_domains[ $property ] ) ) {
						$valid_types = SchemaData::$property_domains[ $property ];
						// Check if this type or any of its parents support the property
						$type_chain = array_merge( array( $type ), self::get_type_parents( $type ) );
						if ( ! empty( array_intersect( $type_chain, $valid_types ) ) ) {
							++$coverage;
						}
					}
				}

				if ( $coverage > $best_coverage ) {
					$best_type     = $type;
					$best_coverage = $coverage;
				}
			}

			if ( null === $best_type ) {
				break; // No type covers remaining properties
			}

			$selected_types[] = $best_type;

			// Remove covered properties
			$type_chain           = array_merge( array( $best_type ), self::get_type_parents( $best_type ) );
			$uncovered_properties = array_filter(
				$uncovered_properties,
				function ( $property ) use ( $type_chain ) {
					if ( ! isset( SchemaData::$property_domains[ $property ] ) ) {
						return true;
					}
					$valid_types = SchemaData::$property_domains[ $property ];
					return empty( array_intersect( $type_chain, $valid_types ) );
				}
			);
		}

		return $selected_types;
	}

	/**
	 * Get all properties grouped by type
	 *
	 * Returns an associative array where keys are type names and values
	 * are arrays of property names that belong to that type.
	 *
	 * @since 6.8.0
	 *
	 * @return array Associative array of type => properties
	 */
	public static function get_properties_by_type() {
		$properties_by_type = array();

		// Build reverse mapping from properties to types
		foreach ( SchemaData::$property_domains as $property => $types ) {
			foreach ( $types as $type ) {
				if ( ! isset( $properties_by_type[ $type ] ) ) {
					$properties_by_type[ $type ] = array();
				}
				$properties_by_type[ $type ][] = $property;
			}
		}

		// Sort properties within each type
		foreach ( $properties_by_type as $type => $properties ) {
			sort( $properties_by_type[ $type ] );
		}

		// Sort by type name
		ksort( $properties_by_type );

		return $properties_by_type;
	}

	/**
	 * Get all properties for a specific type (including inherited)
	 *
	 * Returns all properties that can be used with a type, including
	 * properties inherited from parent types.
	 *
	 * @since 6.8.0
	 *
	 * @param string $type The schema.org type name
	 * @return array Array of property names
	 */
	public static function get_type_properties( $type ) {
		$properties = array();

		// Get type and all its parents
		$types_to_check = array_merge( array( $type ), self::get_type_parents( $type ) );

		// Collect all properties that apply to this type or its parents
		foreach ( SchemaData::$property_domains as $property => $valid_types ) {
			foreach ( $types_to_check as $check_type ) {
				if ( in_array( $check_type, $valid_types, true ) ) {
					$properties[] = $property;
					break;
				}
			}
		}

		sort( $properties );
		return $properties;
	}

	/**
	 * Get the expected types (range) for a property
	 *
	 * Returns the types that a property expects as its value.
	 * For example, 'author' expects ['Person', 'Organization']
	 *
	 * @since 6.8.0
	 *
	 * @param string $property The property name
	 * @return array Array of type names, or empty array if not found
	 */
	public static function get_property_range( $property ) {
		return SchemaData::$property_ranges[ $property ] ?? array();
	}

	/**
	 * Check if a property expects an object (not a primitive type)
	 *
	 * Returns true if the property expects a schema.org Type as its value,
	 * meaning it should be a nested object with @type.
	 *
	 * Primitive types: Text, Number, Boolean, Date, DateTime, Time, URL, etc.
	 *
	 * @since 6.8.0
	 *
	 * @param string $property The property name
	 * @return boolean True if property expects an object
	 */
	public static function property_expects_object( $property ) {
		$range = self::get_property_range( $property );

		if ( empty( $range ) ) {
			return false;
		}

		// Common primitive/data types in schema.org
		$primitive_types = array(
			'Text',
			'Number',
			'Integer',
			'Float',
			'Boolean',
			'Date',
			'DateTime',
			'Time',
			'URL',
			'CssSelectorType',
			'PronounceableText',
			'XPathType',
		);

		// If any range type is not a primitive, it expects an object
		foreach ( $range as $type ) {
			if ( ! in_array( $type, $primitive_types, true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get the preferred object type for a property
	 *
	 * When a property expects an object, this returns the most appropriate type.
	 * For properties with multiple possible types, returns the first one.
	 *
	 * @since 6.8.0
	 *
	 * @param string $property The property name
	 * @return string|null The type name, or null if property doesn't expect an object
	 */
	public static function get_preferred_object_type( $property ) {
		if ( ! self::property_expects_object( $property ) ) {
			return null;
		}

		$range = self::get_property_range( $property );

		// Filter out primitive types
		$primitive_types = array( 'Text', 'Number', 'Integer', 'Float', 'Boolean', 'Date', 'DateTime', 'Time', 'URL', 'CssSelectorType', 'PronounceableText', 'XPathType' );
		$object_types    = array_diff( $range, $primitive_types );

		// Return first object type
		return ! empty( $object_types ) ? reset( $object_types ) : null;
	}
}
