<?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
 * SEOPress PRO Product Schema.
 *
 * @package SEOPress PRO
 * @subpackage Schemas
 */

defined( 'ABSPATH' ) || exit( 'Please don&rsquo;t call the plugin directly. Thanks :)' );

/**
 * Automatic rich snippets products option.
 *
 * @param array $schema_datas The schema datas.
 * @return void
 */
function seopress_automatic_rich_snippets_products_option( $schema_datas ) {

	// If no data.
	if ( 0 != count( array_filter( $schema_datas ) ) ) {
		// Init.
		global $post;
		global $product;

		$products_name = $schema_datas['name'];
		if ( '' == $products_name ) {
			$products_name = the_title_attribute( 'echo=0' );
		}

		$products_description = $schema_datas['description'];
		if ( '' == $products_description ) {
			$products_description = wp_trim_words( esc_html( get_the_excerpt() ), 30 );
		}

		$seopress_thumbnail_size = apply_filters( 'seopress_schemas_post_thumbnail_size', 'large' );

		$products_img = $schema_datas['img'];
		if ( '' == $products_img && '' != get_the_post_thumbnail_url( get_the_ID(), $seopress_thumbnail_size ) ) {
			$products_img = get_the_post_thumbnail_url( get_the_ID(), $seopress_thumbnail_size );
		}

		$products_price = $schema_datas['price'];

		if ( isset( $product ) && '' == $products_price && method_exists( $product, 'get_price' ) && '' != $product->get_price() ) {
			$products_price = $product->get_price();
		}

		$products_price_valid_date = $schema_datas['price_valid_date'];

		if ( isset( $product ) && '' == $products_price_valid_date && method_exists( $product, 'get_date_on_sale_to' ) && '' != $product->get_date_on_sale_to() ) {
			$products_price_valid_date = $product->get_date_on_sale_to();
			$products_price_valid_date = $products_price_valid_date->date( 'm-d-Y' );
		} else {
			$products_price_valid_date = gmdate( 'Y-12-31', time() + YEAR_IN_SECONDS );
		}

		$products_sku = $schema_datas['sku'];
		if ( isset( $product ) && '' == $products_sku && method_exists( $product, 'get_sku' ) && '' != $product->get_sku() ) {
			$products_sku = $product->get_sku();
		}

		$products_global_ids = $schema_datas['global_ids'];

		if ( isset( $product ) && method_exists( $product, 'get_id' ) ) {
			$wc_barcode_type = get_post_meta( $product->get_id(), 'sp_wc_barcode_type_field', true );
			if ( '' !== $wc_barcode_type && 'none' !== $wc_barcode_type ) {
				$products_global_ids = $wc_barcode_type;
			} elseif ( empty( $products_global_ids ) || 'none' === $products_global_ids ) {
				$products_global_ids = 'gtin';
			}
		}

		$products_global_ids_value = $schema_datas['global_ids_value'];

		if ( isset( $product ) && method_exists( $product, 'get_id' ) ) {
			$wc_barcode = get_post_meta( $product->get_id(), 'sp_wc_barcode_field', true );
			if ( '' !== $wc_barcode ) {
				$products_global_ids_value = $wc_barcode;
			} elseif ( method_exists( $product, 'get_global_unique_id' ) ) {
				$wc_global_unique_id = $product->get_global_unique_id();
				if ( '' !== $wc_global_unique_id ) {
					$products_global_ids_value = $wc_global_unique_id;
				}
			}
		}

		$products_brand = $schema_datas['brand'];

		$products_currency = $schema_datas['currency'];
		if ( '' == $products_currency && function_exists( 'get_woocommerce_currency' ) && get_woocommerce_currency() ) {
			$products_currency = get_woocommerce_currency();
		} elseif ( '' == $products_currency && function_exists( 'edd_get_currency' ) && edd_get_currency() ) {
			$products_currency = edd_get_currency();
		} elseif ( '' == $products_currency ) {
			$products_currency = 'USD';
		}

		$products_condition = $schema_datas['condition'];
		if ( '' == $products_condition ) {
			$products_condition = seopress_check_ssl() . 'schema.org/NewCondition';
		}

		$products_availability = $schema_datas['availability'];

		// When no availability is forced in the settings, derive it from the product's real
		// stock status (used by the simple-product branch below; variable products compute
		// availability per variation). A product can be out of stock yet accept backorders,
		// so check is_on_backorder() before is_in_stock().
		if ( '' == $products_availability ) {
			$products_availability = seopress_check_ssl() . 'schema.org/InStock';

			if ( isset( $product ) && is_a( $product, 'WC_Product' ) ) {
				if ( method_exists( $product, 'is_on_backorder' ) && $product->is_on_backorder() ) {
					$products_availability = seopress_check_ssl() . 'schema.org/BackOrder';
				} elseif ( method_exists( $product, 'is_in_stock' ) && ! $product->is_in_stock() ) {
					$products_availability = seopress_check_ssl() . 'schema.org/OutOfStock';
				}
			}
		}

		$json = array(
			'@context'    => seopress_check_ssl() . 'schema.org/',
			'@type'       => 'Product',
			'name'        => $products_name,
			'image'       => $products_img,
			'description' => $products_description,
			'sku'         => $products_sku,
		);

		if ( '' != $products_global_ids && $products_global_ids != 'none' && '' != $products_global_ids_value ) {
			$json[ $products_global_ids ] = $products_global_ids_value;
		}

		// Brand.
		if ( '' != $products_brand ) {
			$json['brand'] = array(
				'@type' => 'Brand',
				'name'  => $products_brand,
			);
		}

		if ( isset( $product ) && true === comments_open( get_the_ID() ) ) { // If Reviews is true.
			// Review.
			$args = array(
				'meta_key'    => 'rating',
				'number'      => 1,
				'status'      => 'approve',
				'post_status' => 'publish',
				'parent'      => 0,
				'orderby'     => 'meta_value_num',
				'order'       => 'DESC',
				'post_id'     => get_the_ID(),
				'post_type'   => 'product',
			);

			$comments = get_comments( $args );

			if ( ! empty( $comments ) ) {
				$json['review'] = array(
					'@type'        => 'Review',
					'reviewRating' => array(
						'@type'       => 'Rating',
						'ratingValue' => get_comment_meta( $comments[0]->comment_ID, 'rating', true ),
					),
					'author'       => array(
						'@type' => 'Person',
						'name'  => get_comment_author( $comments[0]->comment_ID ),
					),
				);
			}

			// AggregateRating.
			if ( isset( $product ) && method_exists( $product, 'get_review_count' ) && $product->get_review_count() >= 1 ) {
				$json['aggregateRating'] = array(
					'@type'       => 'AggregateRating',
					'ratingValue' => $product->get_average_rating(),
					'reviewCount' => $product->get_review_count(),
				);
			}
		} elseif ( isset( $schema_datas['positive_notes'] ) || isset( $schema_datas['negative_notes'] ) ) {

			$json['review'] = array(
				'@type'  => 'Review',
				'author' => array(
					'@type' => 'Person',
					'name'  => get_the_author(),
				),

			);
			if ( ! empty( $schema_datas['positive_notes'] ) ) {
				$json['review']['positiveNotes'] = array(
					'@type'           => 'ItemList',
					'itemListElement' => array(
						'@type'    => 'ListItem',
						'position' => 1,
						'name'     => $schema_datas['positive_notes'],
					),
				);

			}

			if ( ! empty( $schema_datas['negative_notes'] ) ) {
				$json['review']['negativeNotes'] = array(
					'@type'           => 'ItemList',
					'itemListElement' => array(
						'@type'    => 'ListItem',
						'position' => 1,
						'name'     => $schema_datas['negative_notes'],
					),
				);

			}
		}

		// Variable product.
		if ( isset( $product ) && method_exists( $product, 'is_type' ) && $product->is_type( 'variable' ) ) {
			// Build one Offer per published, priced variation regardless of stock status, so
			// the schema always exposes every variant with its real availability
			// (InStock / OutOfStock) — matching Google's product variants guidance, where
			// examples list all variants with their respective availability. We do not use
			// get_available_variations() here: it drops out-of-stock variations when "Hide out
			// of stock items from the catalog" is enabled, which made the schema inconsistent
			// (a sold-out variant silently disappeared, yet two sold-out variants reappeared via
			// the empty-list fallback). variation_is_visible() keeps WooCommerce's own
			// published + has-a-price check, just without the stock filter. Only the keys the
			// loop below reads are built, so this is also lighter than get_available_variation().
			$variations = array();
			$children   = method_exists( $product, 'get_children' ) ? $product->get_children() : array();

			if ( ! empty( $children ) ) {
				// Prime caches to reduce queries, mirroring get_available_variations().
				_prime_post_caches( $children );
			}

			foreach ( $children as $child_id ) {
				$child = wc_get_product( $child_id );
				if ( ! $child || ! $child->exists() || ! method_exists( $child, 'variation_is_visible' ) || ! $child->variation_is_visible() ) {
					continue;
				}
				$variations[] = array(
					'variation_id'        => $child_id,
					'is_in_stock'         => $child->is_in_stock(),
					'sku'                 => $child->get_sku(),
					'seopress_global_ids' => get_post_meta( $child_id, 'seopress_global_ids', true ),
					'seopress_barcode'    => get_post_meta( $child_id, 'seopress_barcode', true ),
				);
			}

			$i = 1;

			foreach ( $variations as $key => $value ) {
				$product_global_ids = $schema_datas['global_ids'];
				$product_barcode    = $schema_datas['global_ids_value'];
				$product_price      = $schema_datas['price'];
				$variation          = wc_get_product( $value['variation_id'] );

				if ( isset( $value['seopress_global_ids'] ) && ! empty( $value['seopress_global_ids'] ) ) {
					$product_global_ids = $value['seopress_global_ids'];
				} elseif ( empty( $product_global_ids ) || 'none' === $product_global_ids ) {
					$product_global_ids = 'gtin';
				}
				if ( isset( $value['seopress_barcode'] ) && ! empty( $value['seopress_barcode'] ) ) {
					$product_barcode = $value['seopress_barcode'];
				} elseif ( isset( $variation ) && method_exists( $variation, 'get_global_unique_id' ) && '' != $variation->get_global_unique_id() ) {
					$product_barcode = $variation->get_global_unique_id();
				}

				$variation_price_valid_date = '';
				if ( isset( $variation ) && '' == $variation_price_valid_date && method_exists( $variation, 'get_date_on_sale_to' ) && '' != $variation->get_date_on_sale_to() ) {
					$variation_price_valid_date = $variation->get_date_on_sale_to();
					$variation_price_valid_date = $variation_price_valid_date->date( 'm-d-Y' );
				} elseif ( ! empty( $schema_datas['price_valid_date'] ) ) {
					try {
						$date                       = new \DateTime( $schema_datas['price_valid_date'] );
						$variation_price_valid_date = $date->format( 'm-d-Y' );
					} catch ( \Exception $e ) {
						$variation_price_valid_date = $schema_datas['price_valid_date'];
					}
				} else {
					$variation_price_valid_date = gmdate( 'Y-12-31', time() + YEAR_IN_SECONDS );
				}

				if ( ! empty( $product_global_ids ) && 'none' === $product_global_ids ) {
					if ( ! empty( $products_global_ids ) ) {
						$product_global_ids = $products_global_ids;
					} else {
						$product_global_ids = 'gtin';
					}
				}

				if ( empty( $product_barcode ) ) {
					$product_barcode = $products_global_ids_value;
				}

				// Map WooCommerce stock status to schema.org availability. is_in_stock() is
				// true for on-backorder products, and a variation can be out of stock yet
				// still allow backorders, so check is_on_backorder() first.
				if ( isset( $variation ) && method_exists( $variation, 'is_on_backorder' ) && $variation->is_on_backorder() ) {
					$availability = sprintf( '%s%s/BackOrder', seopress_check_ssl(), 'schema.org' );
				} elseif ( $value['is_in_stock'] ) {
					$availability = sprintf( '%s%s/InStock', seopress_check_ssl(), 'schema.org' );
				} else {
					$availability = sprintf( '%s%s/OutOfStock', seopress_check_ssl(), 'schema.org' );
				}

				$sku = $schema_datas['sku'];
				if ( empty( $sku ) || 'none' === $sku || $product->get_sku() === $sku ) {
					$sku = empty( $value['sku'] ) ? $product->get_sku() : $value['sku'];
				}

				$variation_price = $product_price;
				if ( isset( $variation ) && function_exists( 'wc_get_price_including_tax' ) && function_exists( 'wc_get_price_excluding_tax' ) ) {
					if ( 'incl' === get_option( 'woocommerce_tax_display_shop' ) ) {
						$variation_price = wc_get_price_including_tax( $variation );
					} else {
						$variation_price = wc_get_price_excluding_tax( $variation );
					}
				}

				// Fall back to the variation's declared price when no active price is
				// computable (e.g. a sold-out variation), so the offer never emits an
				// empty/false price that Google rejects.
				if ( isset( $variation ) && ! is_numeric( $variation_price ) ) {
					$variation_price = seopress_get_wc_product_best_effort_price( $variation );
				}

				$offer = array(
					'@type'           => 'Offer',
					'url'             => $variation->get_permalink(),
					'sku'             => $sku,
					'price'           => is_float( $variation_price ) ? number_format( $variation_price, 2, '.', '' ) : $variation_price,
					'priceCurrency'   => $products_currency,
					'itemCondition'   => $products_condition,
					'availability'    => $availability,
					'priceValidUntil' => $variation_price_valid_date,
				);

				$shipping_details = seopress_get_shipping_schema( $variation );
				if ( ! empty( $shipping_details ) ) {
					$offer['shippingDetails'] = $shipping_details;
				}

				if ( ! empty( $product_barcode ) ) {
					$offer[ $product_global_ids ] = $product_barcode;
				}

				$json['offers'][] = $offer;

				++$i;
			}

			// No published, priced variation produced an Offer (e.g. variations without a
			// price). Google still requires offers even for sold-out products, so emit a
			// single Offer with OutOfStock availability and a best-effort price rather than
			// dropping the whole offers block.
			if ( empty( $json['offers'] ) && method_exists( $product, 'is_in_stock' ) && ! $product->is_in_stock() ) {
				$json['offers'] = seopress_get_out_of_stock_offer_schema( $product, $products_currency, $products_condition, $products_price_valid_date );
			}
		} elseif ( '' != $products_price ) {
			$json['offers'] = array(
				'@type'           => 'Offer',
				'url'             => get_permalink(),
				'priceCurrency'   => $products_currency,
				'price'           => is_float( $products_price ) ? number_format( $products_price, 2, '.', '' ) : $products_price,
				'priceValidUntil' => $products_price_valid_date,
				'itemCondition'   => $products_condition,
				'availability'    => $products_availability,
			);

			$shipping_details = seopress_get_shipping_schema( $product );
			if ( ! empty( $shipping_details ) ) {
				$json['offers']['shippingDetails'] = $shipping_details;
			}
		} elseif ( isset( $product ) && is_a( $product, 'WC_Product' ) && method_exists( $product, 'is_in_stock' ) && ! $product->is_in_stock() ) {
			// Simple product, out of stock, with no active price: keep offers so the
			// product stays valid for Google instead of triggering "Missing field offers".
			// Gated on is_in_stock() so in-stock products without a price of their own
			// (e.g. grouped products) are not mislabelled as OutOfStock.
			$json['offers'] = seopress_get_out_of_stock_offer_schema( $product, $products_currency, $products_condition, $products_price_valid_date );
		}

		$json = array_filter( $json );

		$json = apply_filters( 'seopress_schemas_auto_product_json', $json );

		$json = '<script type="application/ld+json">' . wp_json_encode( $json ) . '</script>' . "\n";

		$json = apply_filters( 'seopress_schemas_auto_product_html', $json );

		echo $json;
	}
}

/**
 * Resolve a declared price for a WooCommerce product, including the case where every
 * variation is out of stock and hidden from the catalog.
 *
 * WooCommerce's get_variation_regular_price('min') relies on get_visible_children(), so
 * it returns false (current() on an empty array) once all variations are hidden — even
 * though each variation still has its regular price stored. We therefore scan every variation
 * (visible or not) and return the lowest declared price, so the schema exposes the real
 * price instead of falling back to 0.
 *
 * @since 10.0.0
 *
 * @param   WC_Product $wc_product The WooCommerce product.
 * @return  string|float|int        A numeric price, or '' when none could be resolved.
 */
function seopress_get_wc_product_best_effort_price( $wc_product ) {
	if ( ! is_a( $wc_product, 'WC_Product' ) ) {
		return '';
	}

	$price = $wc_product->get_price();
	if ( is_numeric( $price ) ) {
		return $price;
	}

	$price = $wc_product->get_regular_price();
	if ( is_numeric( $price ) ) {
		return $price;
	}

	// Variable product whose variations are all hidden (out of stock): read the lowest
	// price declared across every variation, including the hidden ones.
	if ( method_exists( $wc_product, 'is_type' ) && $wc_product->is_type( 'variable' ) && method_exists( $wc_product, 'get_children' ) ) {
		$lowest = '';

		foreach ( $wc_product->get_children() as $variation_id ) {
			$variation = wc_get_product( $variation_id );
			if ( ! $variation ) {
				continue;
			}

			$variation_price = $variation->get_price();
			if ( ! is_numeric( $variation_price ) ) {
				$variation_price = $variation->get_regular_price();
			}

			if ( is_numeric( $variation_price ) && ( '' === $lowest || (float) $variation_price < (float) $lowest ) ) {
				$lowest = $variation_price;
			}
		}

		if ( is_numeric( $lowest ) ) {
			return $lowest;
		}
	}

	return '';
}

/**
 * Build a fallback Offer schema for an out-of-stock WooCommerce product.
 *
 * Google requires the `offers` field even when a product is sold out: it should be
 * kept with `availability: OutOfStock` rather than dropped. Used when a variable
 * product has every variation out of stock (so `get_available_variations()` is empty)
 * or when a simple product has no active price.
 *
 * @since 10.0.0
 *
 * @param   WC_Product $wc_product       The WooCommerce product.
 * @param   string     $currency         The currency code.
 * @param   string     $condition        The schema.org itemCondition URL.
 * @param   string     $price_valid_date The priceValidUntil date.
 * @return  array                        Offer schema.
 */
function seopress_get_out_of_stock_offer_schema( $wc_product, $currency, $condition, $price_valid_date ) {
	$price = seopress_get_wc_product_best_effort_price( $wc_product );

	// Last resort only: keep the offers block valid (a numeric price) when the product
	// genuinely has no price stored anywhere. Such a product can't be a merchant listing
	// anyway, but the Product snippet stays valid instead of breaking on "price": false.
	if ( ! is_numeric( $price ) ) {
		$price = '0';
	}

	// A product can be out of stock yet still accept backorders; reflect that as BackOrder
	// rather than OutOfStock.
	$availability = ( method_exists( $wc_product, 'is_on_backorder' ) && $wc_product->is_on_backorder() ) ? 'BackOrder' : 'OutOfStock';

	$offer = array(
		'@type'           => 'Offer',
		'url'             => get_permalink(),
		'priceCurrency'   => $currency,
		'price'           => is_float( $price ) ? number_format( $price, 2, '.', '' ) : $price,
		'priceValidUntil' => $price_valid_date,
		'itemCondition'   => $condition,
		'availability'    => seopress_check_ssl() . 'schema.org/' . $availability,
	);

	$shipping_details = seopress_get_shipping_schema( $wc_product );
	if ( ! empty( $shipping_details ) ) {
		$offer['shippingDetails'] = $shipping_details;
	}

	return $offer;
}

/**
 * Get shipping schema for a WooCommerce product.
 *
 * @param   WC_Product $wc_product The WooCommerce product.
 * @return  array       $shipping_offers  Schema
 */
function seopress_get_shipping_schema( $wc_product ) {
	if ( ! $wc_product ) {
		return array();
	}

	/**
	 * Filter to disable WooCommerce shippingDetails schema generation.
	 *
	 * @since 9.5.0
	 *
	 * @param bool       $enabled    Whether shippingDetails should be generated.
	 * @param WC_Product $wc_product The current WooCommerce product.
	 */
	$enabled = apply_filters( 'seopress_pro_wc_schema_shipping_details_enabled', true, $wc_product );
	if ( ! $enabled ) {
		return array();
	}

	if ( ! method_exists( $wc_product, 'needs_shipping' ) ) {
		return array();
	}

	$needs_shipping = $wc_product->needs_shipping();
	if ( ! $needs_shipping ) {
		return array();
	}

	$shipping_class_id = (int) $wc_product->get_shipping_class_id();
	$currency          = get_woocommerce_currency();

	// Cache computed shipping offers by shipping class (request-level).
	static $shipping_offers_cache = array();
	$cache_key                    = sprintf( '%d|%s', $shipping_class_id, (string) $currency );
	if ( isset( $shipping_offers_cache[ $cache_key ] ) ) {
		return $shipping_offers_cache[ $cache_key ];
	}

	// Persist cache if an object cache is available.
	$object_cache_key = 'wc_shipping_details_' . md5( $cache_key );
	$cached           = wp_cache_get( $object_cache_key, 'seopress_pro' );
	if ( is_array( $cached ) ) {
		$shipping_offers_cache[ $cache_key ] = $cached;
		return $cached;
	}

	// Create an offer for each rate in each zone.
	$shipping_offers = array();

	// Cache zones (and their shipping methods) per request.
	static $zones_cache = null;
	if ( null === $zones_cache ) {
		$zones_cache = WC_Shipping_Zones::get_zones();
	}

	foreach ( $zones_cache as $zone ) {
		$zone_shipping_destination = array();
		$locations                 = $zone['zone_locations'] ?? array();

		foreach ( $locations as $location ) {
			if ( 'country' === $location->type && $location->code ) {
				$zone_shipping_destination[] = array(
					'@type'          => 'DefinedRegion',
					'addressCountry' => $location->code,
				);
			}
			if ( 'postcode' === $location->type && $location->code ) {
				$zone_shipping_destination[] = array(
					'@type'      => 'DefinedRegion',
					'postalCode' => $location->code,
				);
			}
		}

		foreach ( $zone['shipping_methods'] as $method ) {
			$instance = $method->instance_settings;

			// Free Shipping methods have no cost; min_amount is the order threshold, not the price.
			if ( 'free_shipping' === $method->id ) {
				$cost = 0;
			} else {
				$cost = isset( $instance['cost'] ) ? (float) $instance['cost'] : 0;
			}
			if ( $shipping_class_id && isset( $instance['type'] ) && 'class' === $instance['type'] ) {
				$cost_key = 'class_cost_' . (int) $shipping_class_id;
				if ( ! empty( $instance[ $cost_key ] ) ) {
					$cost += (float) $instance[ $cost_key ];
				}
			}
			$shipping_offers[] = array(
				'@type'               => 'OfferShippingDetails',
				'shippingDestination' => $zone_shipping_destination,
				'shippingRate'        => array(
					'@type'    => 'MonetaryAmount',
					'value'    => $cost,
					'currency' => $currency,
				),
			);
		}
	}

	/**
	 * Filter the generated WooCommerce shippingDetails schema.
	 *
	 * @since 9.5.0
	 *
	 * @param array      $shipping_offers ShippingDetails schema array.
	 * @param WC_Product $wc_product      The current WooCommerce product.
	 */
	$shipping_offers = apply_filters( 'seopress_pro_wc_schema_shipping_details', $shipping_offers, $wc_product );

	$shipping_offers_cache[ $cache_key ] = $shipping_offers;
	wp_cache_set( $object_cache_key, $shipping_offers, 'seopress_pro', HOUR_IN_SECONDS );

	return $shipping_offers;
}
