<?php
/**
 * Livada SEO — WooCommerce integration
 *
 * Emits Product / Offer / AggregateRating / Review JSON-LD on product pages
 * when WooCommerce is active. Integrates with the main schema module by
 * appending its own <script type="application/ld+json"> block (priority 4,
 * one tick after `Livada_SEO_Schema::output()` so the @ids of the main graph
 * are already resolvable).
 *
 * Also extends the sitemap defaults (`product_cat`, `product_tag`) when
 * WooCommerce is detected at runtime.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_WooCommerce {

	/** Number of recent reviews to embed under `review` (Google's rich-result limit). */
	const REVIEW_LIMIT = 5;

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	public static function init(): void {
		// No-op when Woo is not active.
		if ( ! class_exists( 'WooCommerce' ) ) {
			return;
		}

		add_action( 'wp_head', [ self::class, 'output' ], 4 );

		// Auto-include product taxonomies in sitemap defaults when Woo is active
		// and the user hasn't customised the `sitemap_taxonomies` setting yet.
		// Uses `option_*` (post-load) so the saved DB value is preserved when
		// it differs from the defaults — we only augment the still-default case.
		add_filter( 'option_livada_seo_settings', [ self::class, 'augment_settings_for_woo' ] );
	}

	/**
	 * Filters the saved settings array so product-related taxonomies are
	 * included in the sitemap when WooCommerce is active. Non-destructive: the
	 * user can still remove them from the Sitemap settings page.
	 *
	 * @param mixed $value
	 * @return mixed
	 */
	public static function augment_settings_for_woo( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		$wanted_taxes = [ 'product_cat', 'product_tag' ];

		if ( isset( $value['sitemap_taxonomies'] ) && is_array( $value['sitemap_taxonomies'] ) ) {
			$current = $value['sitemap_taxonomies'];
		} else {
			$current = [];
		}

		// Only auto-add when the user hasn't customised taxonomies (still on default `[category]`).
		if ( $current === [ 'category' ] ) {
			$value['sitemap_taxonomies'] = array_values( array_unique( array_merge( $current, $wanted_taxes ) ) );
		}

		// Same logic for post types: add `product` when default is still post+page.
		if ( isset( $value['sitemap_post_types'] ) && is_array( $value['sitemap_post_types'] ) ) {
			$pt = $value['sitemap_post_types'];
			if ( $pt === [ 'post', 'page' ] ) {
				$value['sitemap_post_types'] = [ 'post', 'page', 'product' ];
			}
		}

		return $value;
	}

	// ── Output ────────────────────────────────────────────────────────────────

	public static function output(): void {
		if ( ! is_singular( 'product' ) ) {
			return;
		}

		$product_id = get_the_ID();
		if ( ! $product_id ) {
			return;
		}

		$node = self::build_product_node( (int) $product_id );
		if ( null === $node ) {
			return;
		}

		$schema = [
			'@context' => 'https://schema.org',
			'@graph'   => [ $node ],
		];

		echo '<script type="application/ld+json">' . "\n"
			. wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
			. "\n</script>\n";
	}

	// ── Public helpers (used by REST + MCP preview) ───────────────────────────

	/**
	 * Returns the JSON-LD `Product` node Livada SEO would emit for the given
	 * Woo product. Public so the REST/MCP layer can preview the schema without
	 * having to render a frontend page.
	 *
	 * @param int $product_id
	 * @return array<string,mixed>|null
	 */
	public static function build_product_node( int $product_id ): ?array {
		if ( ! function_exists( 'wc_get_product' ) ) {
			return null;
		}

		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			return null;
		}

		$permalink = (string) get_permalink( $product_id );
		$node = [
			'@type' => 'Product',
			'@id'   => $permalink . '#product',
			'name'  => (string) $product->get_name(),
			'url'   => $permalink,
		];

		// Description: prefer the SEO meta override, fall back to the short description.
		$desc = (string) get_post_meta( $product_id, '_livada_seo_description', true );
		if ( '' === $desc ) {
			$desc = (string) wp_strip_all_tags( (string) $product->get_short_description() );
		}
		if ( '' === $desc ) {
			$desc = (string) wp_strip_all_tags( (string) $product->get_description() );
		}
		if ( '' !== $desc ) {
			$node['description'] = mb_substr( $desc, 0, 500 );
		}

		// Image: featured + gallery (max 6).
		$images = self::collect_product_images( $product );
		if ( ! empty( $images ) ) {
			$node['image'] = count( $images ) === 1 ? $images[0] : $images;
		}

		// SKU.
		$sku = (string) $product->get_sku();
		if ( '' !== $sku ) {
			$node['sku'] = $sku;
		}

		// GTIN / MPN — read from common Woo extension meta keys.
		$gtin = (string) get_post_meta( $product_id, '_global_unique_id', true );
		if ( '' === $gtin ) {
			$gtin = (string) get_post_meta( $product_id, '_gtin', true );
		}
		if ( '' !== $gtin ) {
			$node['gtin'] = $gtin;
		}

		$mpn = (string) get_post_meta( $product_id, '_mpn', true );
		if ( '' !== $mpn ) {
			$node['mpn'] = $mpn;
		}

		// Brand: from `_brand` meta first, then from the brand-style taxonomy.
		$brand = (string) get_post_meta( $product_id, '_livada_seo_product_brand', true );
		if ( '' === $brand ) {
			$brand = (string) get_post_meta( $product_id, '_brand', true );
		}
		if ( '' === $brand ) {
			foreach ( [ 'product_brand', 'pwb-brand', 'pa_brand' ] as $tax ) {
				if ( ! taxonomy_exists( $tax ) ) {
					continue;
				}
				$terms = wp_get_post_terms( $product_id, $tax, [ 'fields' => 'names' ] );
				if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
					$brand = (string) $terms[0];
					break;
				}
			}
		}
		if ( '' !== $brand ) {
			$node['brand'] = [ '@type' => 'Brand', 'name' => $brand ];
		}

		// Offers — single product or one per variation.
		$node['offers'] = self::build_offers( $product );
		if ( null === $node['offers'] ) {
			unset( $node['offers'] );
		}

		// AggregateRating + recent reviews.
		$rating = self::build_aggregate_rating( $product );
		if ( null !== $rating ) {
			$node['aggregateRating'] = $rating;
		}

		$reviews = self::build_recent_reviews( $product_id );
		if ( ! empty( $reviews ) ) {
			$node['review'] = $reviews;
		}

		return $node;
	}

	// ── Sub-builders ──────────────────────────────────────────────────────────

	/**
	 * @return string[] Up to 6 absolute image URLs (featured first, then gallery).
	 */
	private static function collect_product_images( $product ): array {
		$urls = [];

		$featured = (int) $product->get_image_id();
		if ( $featured > 0 ) {
			$src = wp_get_attachment_image_url( $featured, 'full' );
			if ( $src ) {
				$urls[] = $src;
			}
		}

		$gallery = method_exists( $product, 'get_gallery_image_ids' ) ? (array) $product->get_gallery_image_ids() : [];
		foreach ( $gallery as $att_id ) {
			$src = wp_get_attachment_image_url( (int) $att_id, 'full' );
			if ( $src && ! in_array( $src, $urls, true ) ) {
				$urls[] = $src;
			}
			if ( count( $urls ) >= 6 ) {
				break;
			}
		}

		return $urls;
	}

	/**
	 * Builds the `offers` value: a single Offer for simple products, an array
	 * of Offers for variable products, or null when no purchasable price exists.
	 *
	 * @return array<string,mixed>|array<int,array<string,mixed>>|null
	 */
	private static function build_offers( $product ) {
		$currency = function_exists( 'get_woocommerce_currency' ) ? (string) get_woocommerce_currency() : 'EUR';
		$is_variable = $product->is_type( 'variable' );

		if ( $is_variable && method_exists( $product, 'get_available_variations' ) ) {
			$variations = (array) $product->get_available_variations();
			$offers     = [];

			foreach ( $variations as $v ) {
				if ( empty( $v['display_price'] ) || empty( $v['variation_is_visible'] ) ) {
					continue;
				}

				$offers[] = self::single_offer(
					(float) $v['display_price'],
					$currency,
					! empty( $v['is_in_stock'] ),
					(string) ( $v['variation_id'] ?? '' ),
					(string) ( $v['sku'] ?? '' ),
					self::variation_url( $product, $v )
				);
			}

			if ( empty( $offers ) ) {
				return null;
			}

			return $offers;
		}

		// Simple / external / grouped — use the main product price.
		$price = $product->get_price();
		if ( '' === $price || null === $price ) {
			return null;
		}

		return self::single_offer(
			(float) $price,
			$currency,
			$product->is_in_stock(),
			'',
			(string) $product->get_sku(),
			(string) get_permalink( $product->get_id() )
		);
	}

	/**
	 * @return array<string,mixed>
	 */
	private static function single_offer( float $price, string $currency, bool $in_stock, string $variation_id, string $sku, string $url ): array {
		$offer = [
			'@type'         => 'Offer',
			'price'         => $price,
			'priceCurrency' => strtoupper( $currency ),
			'availability'  => $in_stock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
			'url'           => $url,
		];

		if ( '' !== $variation_id ) {
			$offer['@id'] = $url . '#offer-' . $variation_id;
		}

		if ( '' !== $sku ) {
			$offer['sku'] = $sku;
		}

		// priceValidUntil: 6 months from now is a sensible default for ecom items.
		$offer['priceValidUntil'] = gmdate( 'Y-m-d', time() + 6 * MONTH_IN_SECONDS );

		return $offer;
	}

	/**
	 * Best-effort permalink for a Woo variation row. Falls back to the parent
	 * product permalink with the variation_id as a query string.
	 *
	 * @param mixed                $product
	 * @param array<string,mixed>  $variation Row from get_available_variations().
	 */
	private static function variation_url( $product, array $variation ): string {
		$base = (string) get_permalink( $product->get_id() );
		$vid  = (int) ( $variation['variation_id'] ?? 0 );

		if ( $vid > 0 ) {
			return add_query_arg( 'variation_id', $vid, $base );
		}
		return $base;
	}

	/**
	 * @return array<string,mixed>|null
	 */
	private static function build_aggregate_rating( $product ): ?array {
		$count = (int) $product->get_review_count();
		if ( $count <= 0 ) {
			return null;
		}

		$avg = (float) $product->get_average_rating();
		if ( $avg <= 0 ) {
			return null;
		}

		return [
			'@type'       => 'AggregateRating',
			'ratingValue' => round( $avg, 2 ),
			'reviewCount' => $count,
			'bestRating'  => 5,
			'worstRating' => 1,
		];
	}

	/**
	 * @return array<int,array<string,mixed>>
	 */
	private static function build_recent_reviews( int $product_id ): array {
		$comments = get_comments( [
			'post_id'      => $product_id,
			'status'       => 'approve',
			'type'         => 'review',
			'number'       => self::REVIEW_LIMIT,
			'parent'       => 0,
			'orderby'      => 'comment_date',
			'order'        => 'DESC',
		] );

		$out = [];
		foreach ( $comments as $c ) {
			$rating = (int) get_comment_meta( $c->comment_ID, 'rating', true );
			if ( $rating <= 0 || $rating > 5 ) {
				continue;
			}

			$out[] = [
				'@type'         => 'Review',
				'reviewRating'  => [
					'@type'       => 'Rating',
					'ratingValue' => $rating,
					'bestRating'  => 5,
					'worstRating' => 1,
				],
				'author'        => [
					'@type' => 'Person',
					'name'  => $c->comment_author,
				],
				'datePublished' => mysql2date( 'c', $c->comment_date_gmt, false ),
				'reviewBody'    => wp_strip_all_tags( $c->comment_content ),
			];
		}

		return $out;
	}
}
