<?php
/**
 * Product data mapper for Google Shopping Feed.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Marketing;

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/**
 * Maps a WC_Product to an associative array of Google Shopping feed fields.
 *
 * @since 1.0.0
 */
final class Product_Data {

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 *
	 * @param Feed_Config $cfg Module configuration.
	 */
	public function __construct( private Feed_Config $cfg ) {}

	/**
	 * Map a product (or variation) to Google Shopping fields.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product or variation object.
	 * @return array<string, string> Associative array of Google field → value.
	 *                               Empty-string values are omitted by the writer.
	 */
	public function map( \WC_Product $product ): array {
		$data = [];

		// --- Required fields ---
		$data['g:id']           = (string) $product->get_id();
		$data['g:title']        = $this->build_title( $product );
		$data['g:description']  = $this->build_description( $product );
		$data['g:link']         = (string) get_permalink( $product->get_id() );
		$data['g:image_link']   = $this->build_image_link( $product );
		$data['g:availability'] = $this->map_availability( $product );
		// Use regular_price, falling back to get_price() for variations where
		// the price may only be stored as the effective price.
		$regular = $product->get_regular_price();
		if ( '' === $regular ) {
			$regular = $product->get_price();
		}
		$data['g:price'] = $this->format_price( $regular, $product );
		$data['g:condition']    = 'new';

		// --- Identifiers ---
		$gtin  = (string) get_post_meta( $product->get_id(), '_global_unique_id', true );
		$mpn   = (string) get_post_meta( $product->get_id(), $this->cfg->get_mpn_meta_key(), true );
		$brand = $this->build_brand( $product );

		if ( '' !== $gtin ) {
			$data['g:gtin'] = $gtin;
		}
		if ( '' !== $mpn ) {
			$data['g:mpn'] = $mpn;
		}
		if ( '' !== $brand ) {
			$data['g:brand'] = $brand;
		}
		// Only output identifier_exists:no when ALL three are absent.
		if ( '' === $gtin && '' === $mpn && '' === $brand ) {
			$data['g:identifier_exists'] = 'no';
		}

		// --- Sale price ---
		if ( $product->is_on_sale() ) {
			$sale_formatted = $this->format_price( $product->get_sale_price(), $product );
			if ( '' !== $sale_formatted ) {
				$data['g:sale_price'] = $sale_formatted;
				$effective            = $this->build_sale_effective_date( $product );
				if ( '' !== $effective ) {
					$data['g:sale_price_effective_date'] = $effective;
				}
			}
		}

		// --- Grouping ---
		$parent_id = $product->get_parent_id();
		if ( $parent_id > 0 ) {
			$data['g:item_group_id'] = (string) $parent_id;
		}

		// --- Category ---
		$product_type = $this->build_product_type( $product );
		if ( '' !== $product_type ) {
			$data['g:product_type'] = $product_type;
		}

		$gpc = $this->resolve_google_category( $product );
		if ( '' !== $gpc ) {
			$data['g:google_product_category'] = $gpc;
		}

		// --- Additional images ---
		$additional = $this->build_additional_images( $product );
		if ( '' !== $additional ) {
			$data['g:additional_image_link'] = $additional;
		}

		// --- Variation-specific colour/size ---
		if ( $product->is_type( 'variation' ) ) {
			/**
			 * Variation attribute keys checked for g:color, deepest-known first.
			 * Includes the Dutch pa_kleur/kleur — a required apparel field that
			 * was silently dropped on Dutch shops (only pa_maat was accepted for
			 * size, with no colour equivalent).
			 *
			 * @since 1.0.0
			 *
			 * @param string[] $keys Attribute meta keys (e.g. 'attribute_pa_color').
			 */
			$color_keys = (array) apply_filters(
				'kramar_gsf_color_attributes',
				[ 'attribute_pa_color', 'attribute_color', 'attribute_pa_colour', 'attribute_colour', 'attribute_pa_kleur', 'attribute_kleur' ]
			);
			$color = $this->get_variation_attribute( $product, $color_keys );
			if ( '' !== $color ) {
				$data['g:color'] = $color;
			}

			/**
			 * Variation attribute keys checked for g:size.
			 *
			 * @since 1.0.0
			 *
			 * @param string[] $keys Attribute meta keys.
			 */
			$size_keys = (array) apply_filters(
				'kramar_gsf_size_attributes',
				[ 'attribute_pa_size', 'attribute_size', 'attribute_pa_maat', 'attribute_maat' ]
			);
			$size = $this->get_variation_attribute( $product, $size_keys );
			if ( '' !== $size ) {
				$data['g:size'] = $size;
			}
		}

		/**
		 * The complete field map for one feed item, before XML output.
		 *
		 * Lets a site add or correct fields the module doesn't populate — e.g.
		 * g:shipping, g:google_product_category, or GTIN/MPN from a custom source
		 * — without patching the plugin. Returning a field as '' or unsetting it
		 * omits it. If g:identifier_exists needs recomputing after adding a GTIN,
		 * remove it here.
		 *
		 * @since 1.0.0
		 *
		 * @param array<string, string> $data    Field map (key => value).
		 * @param \WC_Product           $product The product (or variation).
		 */
		return (array) apply_filters( 'kramar_gsf_item', $data, $product );
	}

	/**
	 * Build a product title, joining variation attribute values for variants.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product or variation.
	 * @return string Title truncated to 150 chars.
	 */
	private function build_title( \WC_Product $product ): string {
		if ( $product->is_type( 'variation' ) ) {
			$parent = wc_get_product( $product->get_parent_id() );
			$base   = $parent ? $parent->get_name() : $product->get_name();
			$attrs  = $product->get_variation_attributes();
			// Filter empty attributes and decode any taxonomy slugs. The stored
			// value for a global attribute is the term SLUG, so without this the
			// title Google receives reads "Product - rood-qa6" instead of
			// "Product - Rood QA6" — and title is a required Merchant Center field.
			$parts = [];
			foreach ( $attrs as $meta_key => $raw ) {
				if ( '' !== $raw ) {
					$parts[] = $this->attribute_value_to_label( (string) $meta_key, (string) $raw );
				}
			}
			$title = '' !== implode( ', ', $parts ) ? $base . ' - ' . implode( ', ', $parts ) : $base;
		} else {
			$title = $product->get_name();
		}

		return mb_substr( wp_strip_all_tags( $title ), 0, 150 );
	}

	/**
	 * Build a plain-text description from long or short description.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string Description truncated to 5000 chars.
	 */
	private function build_description( \WC_Product $product ): string {
		$desc = $product->get_description();
		if ( '' === $desc && $product->is_type( 'variation' ) ) {
			$parent = wc_get_product( $product->get_parent_id() );
			$desc   = $parent ? $parent->get_description() : '';
		}
		if ( '' === $desc ) {
			$desc = $product->get_short_description();
		}
		if ( '' === $desc && $product->is_type( 'variation' ) ) {
			$parent = wc_get_product( $product->get_parent_id() );
			$desc   = $parent ? $parent->get_short_description() : '';
		}

		$desc = wp_strip_all_tags( $desc );
		$desc = preg_replace( '/\s+/', ' ', (string) $desc ) ?? $desc;
		$desc = trim( $desc );

		return mb_substr( $desc, 0, 5000 );
	}

	/**
	 * Get full-size image URL for the product's main image.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string URL or empty string.
	 */
	private function build_image_link( \WC_Product $product ): string {
		$image_id = $product->get_image_id();
		if ( ! $image_id && $product->is_type( 'variation' ) ) {
			$parent   = wc_get_product( $product->get_parent_id() );
			$image_id = $parent ? $parent->get_image_id() : 0;
		}
		if ( ! $image_id ) {
			return '';
		}
		$url = wp_get_attachment_image_url( (int) $image_id, 'full' );
		return $url ? (string) $url : '';
	}

	/**
	 * Build comma-separated list of additional image URLs (max 10).
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string Comma-separated URLs or empty string.
	 */
	private function build_additional_images( \WC_Product $product ): string {
		$gallery_ids = $product->get_gallery_image_ids();
		if ( empty( $gallery_ids ) && $product->is_type( 'variation' ) ) {
			$parent      = wc_get_product( $product->get_parent_id() );
			$gallery_ids = $parent ? $parent->get_gallery_image_ids() : [];
		}
		$urls = [];
		foreach ( array_slice( $gallery_ids, 0, 10 ) as $id ) {
			$url = wp_get_attachment_image_url( (int) $id, 'full' );
			if ( $url ) {
				$urls[] = $url;
			}
		}
		return implode( ', ', $urls );
	}

	/**
	 * Map WooCommerce stock status to Google availability value.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string Google availability string.
	 */
	private function map_availability( \WC_Product $product ): string {
		return match ( $product->get_stock_status() ) {
			'instock'     => 'in_stock',
			'outofstock'  => 'out_of_stock',
			'onbackorder' => 'backorder',
			default       => 'out_of_stock',
		};
	}

	/**
	 * Build the brand value for a product.
	 *
	 * Resolution order: the configured product attribute (e.g. pa_brand), then a
	 * brand *taxonomy* — WooCommerce's native product_brand (WC 6.8+/Core Brands)
	 * and the common third-party ones — because get_attribute() cannot read a
	 * standalone taxonomy, only product/variation attributes. Variations inherit
	 * their parent's brand.
	 *
	 * There is deliberately NO site-name fallback: labelling every product with
	 * the shop name is wrong data that gets a Merchant Center feed disapproved,
	 * and a missing brand is better than a wrong one. Returns '' when unknown.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string Brand, or '' when none can be determined.
	 */
	private function build_brand( \WC_Product $product ): string {
		$parent   = $product->is_type( 'variation' ) ? wc_get_product( $product->get_parent_id() ) : null;
		$brand    = '';

		// 1. Configured product attribute (back-compat for shops using pa_brand).
		$attr_slug = $this->cfg->get_brand_attribute();
		if ( '' !== $attr_slug ) {
			$brand = (string) $product->get_attribute( $attr_slug );
			if ( '' === $brand && $parent ) {
				$brand = (string) $parent->get_attribute( $attr_slug );
			}
		}

		// 2. Brand taxonomies. WooCommerce's own product_brand first, then the
		//    established third-party ones, so a real brand is found without any
		//    configuration on shops that use the native/plugin taxonomy.
		if ( '' === $brand ) {
			/**
			 * Taxonomies consulted for a product's brand, in order.
			 *
			 * @since 1.0.0
			 *
			 * @param string[] $taxonomies Taxonomy slugs.
			 */
			$taxonomies = (array) apply_filters(
				'kramar_gsf_brand_taxonomies',
				[ 'product_brand', 'pwb-brand', 'yith_product_brand', 'berocket_brand' ]
			);
			$term_source = $parent ? $parent->get_id() : $product->get_id();
			foreach ( $taxonomies as $taxonomy ) {
				if ( ! taxonomy_exists( $taxonomy ) ) {
					continue;
				}
				$terms = get_the_terms( $term_source, $taxonomy );
				if ( is_array( $terms ) && ! empty( $terms ) && ! is_wp_error( $terms ) ) {
					$brand = (string) $terms[0]->name;
					break;
				}
			}
		}

		/**
		 * The resolved brand for a feed item. Return '' for no brand.
		 *
		 * @since 1.0.0
		 *
		 * @param string      $brand   Resolved brand ('' if none).
		 * @param \WC_Product $product The product (or variation).
		 */
		return (string) apply_filters( 'kramar_gsf_product_brand', $brand, $product );
	}

	/**
	 * Resolve the Google product category for a product.
	 *
	 * Looks up the product's primary WooCommerce category in the configured
	 * category map, walking up the category tree — the most specific mapped
	 * ancestor wins, so a shop only has to map its top-level categories for
	 * everything beneath them to resolve. Falls back to the single global
	 * default when nothing in the chain is mapped, so a one-category shop keeps
	 * working with just that field.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product (or variation).
	 * @return string Google category (numeric id or path), or '' when none.
	 */
	private function resolve_google_category( \WC_Product $product ): string {
		$map = $this->cfg->get_category_map();

		if ( ! empty( $map ) ) {
			$product_id = $product->get_parent_id() > 0 ? $product->get_parent_id() : $product->get_id();
			$terms      = get_the_terms( $product_id, 'product_cat' );

			if ( is_array( $terms ) && ! empty( $terms ) && ! is_wp_error( $terms ) ) {
				$term = reset( $terms );
				// term itself first, then ancestors closest-to-furthest: most
				// specific mapping wins.
				$chain = array_merge( [ (int) $term->term_id ], get_ancestors( (int) $term->term_id, 'product_cat' ) );
				foreach ( $chain as $tid ) {
					if ( isset( $map[ $tid ] ) && '' !== $map[ $tid ] ) {
						return (string) $map[ $tid ];
					}
				}
			}
		}

		return $this->cfg->get_google_product_category();
	}

	/**
	 * Build a product type breadcrumb string from WC categories.
	 *
	 * Uses the first (primary) product category and its parents.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string Category path like "Clothing > Shirts" or empty string.
	 */
	private function build_product_type( \WC_Product $product ): string {
		$product_id = $product->get_parent_id() > 0 ? $product->get_parent_id() : $product->get_id();
		$terms      = get_the_terms( $product_id, 'product_cat' );

		if ( ! is_array( $terms ) || empty( $terms ) ) {
			return '';
		}

		// Use the first term.
		$term = reset( $terms );

		// Build ancestors breadcrumb.
		$ancestor_ids = array_reverse( get_ancestors( $term->term_id, 'product_cat' ) );
		$parts        = [];

		foreach ( $ancestor_ids as $ancestor_id ) {
			$ancestor = get_term( $ancestor_id, 'product_cat' );
			if ( $ancestor instanceof \WP_Term ) {
				$parts[] = $ancestor->name;
			}
		}
		$parts[] = $term->name;

		return implode( ' > ', $parts );
	}

	/**
	 * Build ISO 8601 sale effective date range string.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product.
	 * @return string Date range like "2025-06-01T00:00+02:00/2025-06-30T23:59+02:00" or empty.
	 */
	private function build_sale_effective_date( \WC_Product $product ): string {
		$from = $product->get_date_on_sale_from();
		$to   = $product->get_date_on_sale_to();

		if ( ! $from || ! $to ) {
			return '';
		}

		$offset = (float) get_option( 'gmt_offset', 0 );
		$sign   = $offset >= 0 ? '+' : '-';
		$hours  = str_pad( (string) abs( (int) $offset ), 2, '0', STR_PAD_LEFT );
		$tz     = $sign . $hours . ':00';

		return $from->date( 'Y-m-d\TH:i' ) . $tz . '/' . $to->date( 'Y-m-d\TH:i' ) . $tz;
	}

	/**
	 * Format a raw price string with tax handling and currency suffix.
	 *
	 * @since 1.0.0
	 *
	 * @param string|null $raw_price Raw WC price (may be '' or null if not set).
	 * @param \WC_Product $product   Product (needed for tax calculation).
	 * @return string Formatted price like "19.99 EUR" or empty string.
	 */
	private function format_price( ?string $raw_price, \WC_Product $product ): string {
		if ( null === $raw_price || '' === $raw_price ) {
			return '';
		}

		$use_tax_inclusive = 'incl' === get_option( 'woocommerce_tax_display_shop', 'excl' );

		// Both directions have to be converted. The stored price is whatever
		// "prices include tax" says it is, which need not match how the shop
		// displays. This used to cast the raw price straight through when the
		// shop displays excluding tax — correct only when prices are also stored
		// excluding tax. A shop storing tax-inclusive prices and displaying them
		// excluding tax sent Google a price a full VAT rate above its own landing
		// page: 12,34 in the feed against 10,20 on the page.
		if ( $use_tax_inclusive ) {
			$price = (float) wc_get_price_including_tax( $product, [ 'price' => $raw_price ] );
		} else {
			$price = (float) wc_get_price_excluding_tax( $product, [ 'price' => $raw_price ] );
		}

		return number_format( $price, 2, '.', '' ) . ' ' . get_woocommerce_currency();
	}

	/**
	 * Get a variation attribute value by checking multiple possible meta keys.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product   $product Variation product.
	 * @param array<string> $keys   Ordered list of meta/attribute keys to try.
	 * @return string Attribute value or empty string.
	 */
	private function get_variation_attribute( \WC_Product $product, array $keys ): string {
		foreach ( $keys as $key ) {
			$value = (string) get_post_meta( $product->get_id(), $key, true );
			if ( '' !== $value ) {
				return $this->attribute_value_to_label( $key, $value );
			}
		}
		return '';
	}

	/**
	 * Resolve a stored variation attribute value to what a shopper would read.
	 *
	 * Global (taxonomy) attributes store the term slug on the variation, so the
	 * raw value is "rood-qa6" where the feed needs "Rood QA6". Custom per-product
	 * attributes already store the label, and are returned unchanged.
	 *
	 * @since 1.0.2
	 *
	 * @param string $meta_key Variation meta key, e.g. attribute_pa_colour.
	 * @param string $value    Stored value (a term slug for taxonomy attributes).
	 */
	private function attribute_value_to_label( string $meta_key, string $value ): string {
		if ( '' === $value ) {
			return '';
		}

		$taxonomy = 0 === strpos( $meta_key, 'attribute_' ) ? substr( $meta_key, strlen( 'attribute_' ) ) : $meta_key;

		if ( taxonomy_exists( $taxonomy ) ) {
			$term = get_term_by( 'slug', $value, $taxonomy );
			if ( $term instanceof \WP_Term ) {
				return $term->name;
			}
		}

		return $value;
	}
}
