<?php
/**
 * Shared schema value inspection helpers.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

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

/**
 * Provides non-mutating helpers used by local validation rules.
 */
class ValueInspector {

	/**
	 * Determine whether an array is a sequential list.
	 *
	 * @param array $value Candidate array.
	 * @return bool
	 */
	public static function is_list( $value ) {
		if ( ! is_array( $value ) ) {
			return false;
		}
		if ( array() === $value ) {
			return true;
		}

		return array_keys( $value ) === range( 0, count( $value ) - 1 );
	}

	/**
	 * Get valid type names from an entity.
	 *
	 * @param array $entity Schema entity.
	 * @return string[]
	 */
	public static function get_types( $entity ) {
		if ( ! is_array( $entity ) || ! array_key_exists( '@type', $entity ) ) {
			return array();
		}

		$types = is_array( $entity['@type'] ) ? $entity['@type'] : array( $entity['@type'] );
		$types = array_filter(
			$types,
			function ( $type ) {
				return is_string( $type ) && '' !== trim( $type );
			}
		);

		return array_values( $types );
	}

	/**
	 * Get an entity identifier when it is a string.
	 *
	 * @param array $entity Schema entity.
	 * @return string
	 */
	public static function get_id( $entity ) {
		if ( ! is_array( $entity ) || ! isset( $entity['@id'] ) || ! is_string( $entity['@id'] ) ) {
			return '';
		}

		return $entity['@id'];
	}

	/**
	 * Determine whether an object is an @id-only reference.
	 *
	 * @param array $value Candidate object.
	 * @return bool
	 */
	public static function is_reference( $value ) {
		return is_array( $value ) && ! self::is_list( $value ) && array( '@id' ) === array_keys( $value );
	}

	/**
	 * Determine whether an object explicitly describes an entity.
	 *
	 * @param array $value Candidate object.
	 * @return bool
	 */
	public static function is_entity( $value ) {
		return is_array( $value ) && ! self::is_list( $value ) && ! self::is_reference( $value ) && ( array_key_exists( '@type', $value ) || array_key_exists( '@id', $value ) );
	}

	/**
	 * Determine whether a value is structurally empty.
	 *
	 * @param mixed $value Candidate value.
	 * @return bool
	 */
	public static function is_empty( $value ) {
		return null === $value || ( is_string( $value ) && '' === trim( $value ) ) || ( is_array( $value ) && array() === $value );
	}

	/**
	 * Walk every property in a schema value without mutating it.
	 *
	 * @param mixed    $value    Schema value.
	 * @param string   $path     Current property path.
	 * @param callable $callback Callback receiving property, value, path, and depth.
	 * @param int      $depth    Current depth.
	 * @param string   $list_property Parent property for scalar list items.
	 * @return void
	 */
	public static function walk( $value, $path, $callback, $depth = 0, $list_property = '' ) {
		if ( ! is_array( $value ) ) {
			return;
		}

		if ( self::is_list( $value ) ) {
			foreach ( $value as $index => $item ) {
				$item_path = $path . '[' . $index . ']';
				if ( is_array( $item ) ) {
					self::walk( $item, $item_path, $callback, $depth + 1, $list_property );
				} else {
					call_user_func( $callback, $list_property, $item, $item_path, $depth );
				}
			}
			return;
		}

		foreach ( $value as $property => $item ) {
			$property  = (string) $property;
			$item_path = '' === $path ? $property : $path . '.' . $property;
			call_user_func( $callback, $property, $item, $item_path, $depth );
			if ( is_array( $item ) ) {
				self::walk( $item, $item_path, $callback, $depth + 1, $property );
			}
		}
	}

	/**
	 * Determine whether a property normally contains a URL value.
	 *
	 * @param string $property Property name.
	 * @return bool
	 */
	public static function is_url_property( $property ) {
		$properties = array(
			'@id',
			'url',
			'image',
			'logo',
			'sameAs',
			'contentUrl',
			'embedUrl',
			'item',
			'target',
			'availability',
		);

		return in_array( $property, $properties, true ) || 0 === substr_compare( $property, 'url', -3, 3, true );
	}

	/**
	 * Determine whether a string is an acceptable URL or local @id reference.
	 *
	 * @param string $value URL candidate.
	 * @param string $property Property name.
	 * @return bool
	 */
	public static function is_valid_url( $value, $property ) {
		if ( '@id' === $property ) {
			return '' !== $value && ! preg_match( '/\\s/', $value );
		}

		$parts = wp_parse_url( $value );
		if ( ! is_array( $parts ) ) {
			return false;
		}

		return isset( $parts['scheme'], $parts['host'] ) && in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true );
	}

	/**
	 * Determine whether a property is date-like in Schema.org data.
	 *
	 * @param string $property Property name.
	 * @return bool
	 */
	public static function is_date_property( $property ) {
		return in_array(
			$property,
			array( 'datePublished', 'dateModified', 'dateCreated', 'datePosted', 'startDate', 'endDate', 'validFrom', 'validThrough' ),
			true
		);
	}

	/**
	 * Determine whether a string is a date or ISO 8601 date-time.
	 *
	 * @param string $value Date candidate.
	 * @return bool
	 */
	public static function is_valid_date( $value ) {
		if ( ! is_string( $value ) || ! preg_match( '/^(\\d{4})-(\\d{2})-(\\d{2})(?:T(\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:\\d{2})?)?$/', $value, $matches ) ) {
			return false;
		}

		if ( ! checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ) ) {
			return false;
		}

		if ( isset( $matches[4] ) && ( (int) $matches[4] > 23 || (int) $matches[5] > 59 || ( isset( $matches[6] ) && '' !== $matches[6] && (int) $matches[6] > 59 ) ) ) {
			return false;
		}

		if ( preg_match( '/[+-](\\d{2}):(\\d{2})$/', $value, $timezone ) && ( (int) $timezone[1] > 23 || (int) $timezone[2] > 59 ) ) {
			return false;
		}

		return true;
	}
}
