<?php
/**
 * ERENQVP_Product — create simple/variable products fast from a variation matrix.
 *
 * Generalized from the original single-tenant module: instead of two hard-coded axes
 * (pa_beden/pa_renk), it accepts ANY number of attribute axes resolved at runtime.
 * Robustness: third-party post hooks are muted while the product + variations are written
 * (so a broken save_post closure can't abort the loop mid-way), then the key integration
 * hooks are re-fired once for the finished product so SEO/translation/indexer plugins still see it.
 *
 * @package ErenLabs\QuickVariableProducts
 */

defined( 'ABSPATH' ) || exit;

class ERENQVP_Product {

	/**
	 * Maximum number of rows handled by one synchronous create request.
	 *
	 * @return int
	 */
	public static function max_variations() {
		return max( 1, min( 10000, absint( apply_filters( 'erenqvp_max_variations_per_request', 1500 ) ) ) );
	}

	/**
	 * Create a product from the matrix payload.
	 *
	 * Expected $d keys:
	 *  - name (string), description (string), image_id (int)
	 *  - category_ids (int[])
	 *  - regular_price (string|float)  default price for every variation
	 *  - sale_price (string|float)     optional default sale price
	 *  - manage_stock (bool)           default true
	 *  - stock (int)                   simple-product stock fallback
	 *  - auto_sku (bool)               fill empty SKUs with a generated code
	 *  - variations: [ { attrs: { "pa_color":"Red", ... }, stock, sku, regular_price, sale_price, image_id } ]
	 *
	 * @param array $d Payload.
	 * @return array { ok, ... } result for the UI.
	 */
	public static function create_product( array $d ) {
		// Sanitize the whole decoded payload up front, so every value — including the
		// variation rows passed to the erenqvp_variation_saved action — is clean before use.
		$d = self::sanitize_create_payload( $d );

		$name = sanitize_text_field( $d['name'] ?? '' );
		if ( '' === $name ) {
			return array(
				'ok'  => false,
				'msg' => __( 'Product name is required.', 'quick-variable-products' ),
			);
		}

		$regular = self::dec_or_blank( $d['regular_price'] ?? '' );
		if ( '' === $regular || (float) $regular <= 0 ) {
			return array(
				'ok'  => false,
				'msg' => __( 'Enter a valid regular price.', 'quick-variable-products' ),
			);
		}
		$sale = self::dec_or_blank( $d['sale_price'] ?? '' );

		$cats         = array_values( array_filter( array_map( 'absint', (array) ( $d['category_ids'] ?? array() ) ) ) );
		$desc         = wp_kses_post( $d['description'] ?? '' );
		$image_id     = self::valid_image_id( $d['image_id'] ?? 0 );
		$gallery_ids  = self::valid_image_ids( (array) ( $d['gallery_ids'] ?? array() ) );
		$cost         = self::dec_or_blank( $d['cost'] ?? '' );
		$auto_sku     = ! empty( $d['auto_sku'] );
		$manage_stock = array_key_exists( 'manage_stock', $d ) ? (bool) $d['manage_stock'] : true;
		$variations   = ( isset( $d['variations'] ) && is_array( $d['variations'] ) ) ? $d['variations'] : array();
		if ( count( $variations ) > self::max_variations() ) {
			return array(
				'ok'  => false,
				/* translators: %d: maximum variations allowed in one request. */
				'msg' => sprintf( __( 'This request has too many variations. The safe limit is %d; split it into smaller products or batches.', 'quick-variable-products' ), self::max_variations() ),
			);
		}
		// Shared stock pool (opt-in, variable only): ONE quantity managed on the parent; the
		// variations inherit it (native WC parent-level stock). Per-row quantities are ignored.
		$shared_stock = $manage_stock && ! empty( $d['shared_stock'] );
		$pool_qty     = max( 0, (int) ( $d['shared_pool_qty'] ?? 0 ) );

		// Tax / inventory / shipping (all optional, sticky on the client).
		$f = array(
			'tax_status'        => self::sanitize_tax_status( $d['tax_status'] ?? null ),
			'tax_class'         => self::sanitize_tax_class( $d['tax_class'] ?? null ),
			'backorders'        => self::sanitize_backorders( $d['backorders'] ?? null ),
			'shipping_class_id' => absint( $d['shipping_class_id'] ?? 0 ),
			'weight'            => self::dec_or_blank( $d['weight'] ?? '' ),
			'length'            => self::dec_or_blank( $d['length'] ?? '' ),
			'width'             => self::dec_or_blank( $d['width'] ?? '' ),
			'height'            => self::dec_or_blank( $d['height'] ?? '' ),
		);
		$brand_id   = ERENQVP_Attributes::resolve_brand( $d['brand'] ?? '' );
		$short_desc = wp_kses_post( $d['short_description'] ?? '' );
		$status     = ( isset( $d['status'] ) && 'draft' === $d['status'] ) ? 'draft' : 'publish';
		$tags_in    = $d['tags'] ?? array();
		if ( is_string( $tags_in ) ) { // tolerate a comma list, e.g. from templates or direct API use
			$tags_in = array_map( 'trim', explode( ',', $tags_in ) );
		}
		$tag_ids = ERENQVP_Attributes::resolve_tag_ids( is_array( $tags_in ) ? $tags_in : array() );

		$made_id       = 0;
		$made_ids      = array(); // variation ids whose muted integration hooks are replayed after the build
		$sku_conflicts = array(); // SKUs dropped because they were already taken (surfaced to the user)
		$sale_dropped  = 0;       // count of sale prices ignored because they were not below the regular price
		self::mute_post_hooks();
		try {
			// Collect the attribute axes + values actually used by the rows.
			$use = array(); // taxonomy => [ valueName => true ]
			foreach ( $variations as $v ) {
				$attrs = ( isset( $v['attrs'] ) && is_array( $v['attrs'] ) ) ? $v['attrs'] : array();
				foreach ( $attrs as $tax => $val ) {
					$tax = ERENQVP_Attributes::sanitize_taxonomy( $tax );
					$val = trim( (string) $val );
					if ( '' !== $tax && '' !== $val ) {
						$use[ $tax ][ $val ] = true;
					}
				}
			}

			$has_variations = false;
			foreach ( $use as $vals ) {
				if ( ! empty( $vals ) ) {
					$has_variations = true;
					break;
				}
			}

			/* ── Simple product ── */
			if ( ! $has_variations ) {
				$p = new WC_Product_Simple();
				$p->set_name( $name );
				$p->set_status( $status );
				$p->set_regular_price( $regular );
				if ( '' !== $sale && (float) $sale > 0 && (float) $sale < (float) $regular ) {
					$p->set_sale_price( $sale );
				} elseif ( '' !== $sale && (float) $sale > 0 ) {
					++$sale_dropped; // sale entered but not below regular -> ignored, surfaced to the user
				}
				if ( $cats ) {
					$p->set_category_ids( $cats );
				}
				if ( $desc ) {
					$p->set_description( $desc );
				}
				if ( $short_desc ) {
					$p->set_short_description( $short_desc );
				}
				if ( $tag_ids ) {
					$p->set_tag_ids( $tag_ids );
				}
				$row   = $variations[0] ?? array();
				$stock = max( 0, isset( $row['stock'] ) ? (int) $row['stock'] : (int) ( $d['stock'] ?? 0 ) );
				if ( $manage_stock ) {
					$p->set_manage_stock( true );
					$p->set_stock_quantity( $stock );
					$p->set_stock_status( self::stock_status_for( $stock, $f['backorders'] ) );
				}
				if ( $image_id ) {
					$p->set_image_id( $image_id );
				}
				if ( $gallery_ids ) {
					$p->set_gallery_image_ids( $gallery_ids );
				}
				if ( '' !== $cost ) {
					self::apply_cogs( $p, $cost );
				}
				self::apply_common_fields( $p, $f );
				$sku = isset( $row['sku'] ) ? sanitize_text_field( $row['sku'] ) : '';
				if ( '' === $sku && $auto_sku ) {
					$sku = ERENQVP_Barcode::generate();
				}
				if ( '' !== $sku ) {
					try {
						$p->set_sku( $sku );
					} catch ( \WC_Data_Exception $e ) {
						$sku_conflicts[] = $sku; // already used — dropped, reported to the user
						$sku = '';
					}
				}
				$pid = $p->save();
				if ( ! $pid ) {
					return array(
						'ok'  => false,
						'msg' => __( 'Could not save the product.', 'quick-variable-products' ),
					);
				}
				if ( '' !== $cost ) {
					self::save_cost_meta( $pid, $cost );
				}
				if ( $brand_id ) {
					wp_set_object_terms( $pid, array( $brand_id ), 'product_brand' );
				}
				$made_id = $pid;
				return array(
					'ok'            => true,
					'product_id'    => $pid,
					'type'          => 'simple',
					'variations'    => 0,
					'sku_conflicts' => $sku_conflicts,
				'sale_dropped'  => $sale_dropped,
					'edit'          => admin_url( 'post.php?post=' . $pid . '&action=edit' ),
					'labels'        => array(
						array(
							'name'    => $name,
							'variant' => '',
							'price'   => (float) $regular,
							'sku'     => $sku,
							'stock'   => $stock,
						),
					),
				);
			}

			/* ── Variable product ── */
			$slugmap = array(); // taxonomy => [ valueName => slug ]
			$termids = array(); // taxonomy => [ term_id, ... ]
			foreach ( $use as $tax => $names ) {
				ERENQVP_Attributes::ensure_registered( $tax );
				foreach ( array_keys( $names ) as $nm ) {
					$term = ERENQVP_Attributes::resolve_term( $tax, $nm );
					if ( $term ) {
						$slugmap[ $tax ][ $nm ] = $term->slug;
						$termids[ $tax ][]      = (int) $term->term_id;
					}
				}
			}
			if ( empty( $termids ) ) {
				return array(
					'ok'  => false,
					'msg' => __( 'Could not resolve the variation attributes.', 'quick-variable-products' ),
				);
			}

			$product = new WC_Product_Variable();
			$product->set_name( $name );
			$product->set_status( $status );
			if ( $cats ) {
				$product->set_category_ids( $cats );
			}
			if ( $desc ) {
				$product->set_description( $desc );
			}
			if ( $short_desc ) {
				$product->set_short_description( $short_desc );
			}
			if ( $tag_ids ) {
				$product->set_tag_ids( $tag_ids );
			}

			$attributes = array();
			$position   = 0;
			foreach ( $termids as $tax => $ids ) {
				$a = new WC_Product_Attribute();
				$a->set_id( wc_attribute_taxonomy_id_by_name( $tax ) );
				$a->set_name( $tax );
				$a->set_options( array_values( array_unique( $ids ) ) );
				$a->set_position( $position++ );
				$a->set_visible( true );
				$a->set_variation( true );
				$attributes[] = $a;
			}
			$product->set_attributes( $attributes );
			if ( $image_id ) {
				$product->set_image_id( $image_id );
			}
			if ( $gallery_ids ) {
				$product->set_gallery_image_ids( $gallery_ids );
			}
			self::apply_common_fields( $product, $f );
			$pid = $product->save();
			if ( ! $pid ) {
				return array(
					'ok'  => false,
					'msg' => __( 'Could not save the product.', 'quick-variable-products' ),
				);
			}
			foreach ( $termids as $tax => $ids ) {
				wp_set_object_terms( $pid, array_values( array_unique( $ids ) ), $tax );
			}

			$count           = 0;
			$made            = array();
			$made_labels     = array();
			$rows_unresolved = 0; // rows skipped because a typed axis value could not be resolved to a term
			foreach ( $variations as $v ) {
				$attrs_in = ( isset( $v['attrs'] ) && is_array( $v['attrs'] ) ) ? $v['attrs'] : array();
				$attrs    = array();      // taxonomy => slug
				$labelbits = array();     // human names, for label text
				$wanted    = 0;           // axes this row explicitly typed (non-blank cells)
				$resolved  = 0;           // of those, how many mapped to a real term
				foreach ( $attrs_in as $tax => $val ) {
					$tax = ERENQVP_Attributes::sanitize_taxonomy( $tax );
					$val = trim( (string) $val );
					if ( '' === $tax || '' === $val ) {
						continue; // blank axis cell — intentionally "Any" for this axis
					}
					++$wanted;
					if ( isset( $slugmap[ $tax ][ $val ] ) ) {
						$attrs[ $tax ] = $slugmap[ $tax ][ $val ];
						$labelbits[]   = $val;
						++$resolved;
					}
				}
				if ( $wanted > 0 && $resolved !== $wanted ) {
					// A typed axis value could not be resolved to a term (e.g. a brand-new value
					// this user lacks the capability to create). Skip the whole row rather than
					// saving an over-broad "Any <axis>" variation — which would also collapse with
					// its siblings under a shorter attribute signature and hide the loss.
					++$rows_unresolved;
					continue;
				}
				if ( empty( $attrs ) ) {
					continue;
				}
				ksort( $attrs );
				$sig = implode( '|', array_map( function ( $k, $val ) {
					return $k . '=' . $val;
				}, array_keys( $attrs ), $attrs ) );
				if ( isset( $made[ $sig ] ) ) {
					continue; // duplicate combination
				}
				$made[ $sig ] = true;

				$var = new WC_Product_Variation();
				$var->set_parent_id( $pid );
				$var->set_attributes( $attrs );

				$vimg = isset( $v['image_id'] ) ? self::valid_image_id( $v['image_id'] ) : 0;
				if ( $vimg ) {
					$var->set_image_id( $vimg );
				}

				$vprice = self::dec_or_blank( $v['regular_price'] ?? '' );
				if ( '' === $vprice || (float) $vprice <= 0 ) {
					$vprice = $regular;
				}
				$var->set_regular_price( $vprice );
				$vsale = ( isset( $v['sale_price'] ) && '' !== $v['sale_price'] ) ? self::dec_or_blank( $v['sale_price'] ) : $sale;
				if ( '' !== $vsale && (float) $vsale > 0 && (float) $vsale < (float) $vprice ) {
					$var->set_sale_price( $vsale );
				} elseif ( '' !== $vsale && (float) $vsale > 0 ) {
					++$sale_dropped; // sale not below this variation's regular price -> ignored
				}

				$vstock = max( 0, isset( $v['stock'] ) ? (int) $v['stock'] : 0 );
				if ( $shared_stock ) {
					// Pool mode: the variation does NOT manage its own stock — WC falls back to
					// the parent's quantity, so every sale draws from the one pool.
					$var->set_manage_stock( false );
					if ( null !== $f['backorders'] ) {
						$var->set_backorders( $f['backorders'] );
					}
					$var->set_stock_status( self::stock_status_for( $pool_qty, $f['backorders'] ) );
					$vstock = 0; // per-row quantities are meaningless in pool mode
				} elseif ( $manage_stock ) {
					$var->set_manage_stock( true );
					$var->set_stock_quantity( $vstock );
					if ( null !== $f['backorders'] ) {
						$var->set_backorders( $f['backorders'] );
					}
					$var->set_stock_status( self::stock_status_for( $vstock, $f['backorders'] ) );
				}

				$sku = isset( $v['sku'] ) ? sanitize_text_field( $v['sku'] ) : '';
				if ( '' === $sku && $auto_sku ) {
					$sku = ERENQVP_Barcode::generate();
				}
				if ( '' !== $sku ) {
					try {
						$var->set_sku( $sku );
					} catch ( \WC_Data_Exception $e ) {
						$sku_conflicts[] = $sku; // already used — dropped, reported to the user
						$sku = '';
					}
				}

				if ( '' !== $cost ) {
					self::apply_cogs( $var, $cost );
				}
				$vid = $var->save();
				if ( ! $vid ) {
					continue;
				}
				$made_ids[] = (int) $vid;
				// Integration seam: the Pro add-on writes per-value variation GALLERIES here
				// (native WC storage + active gallery-plugin formats). Free stays single-image.
				do_action( 'erenqvp_variation_saved', $vid, $v, $pid );
				if ( '' !== $cost ) {
					self::save_cost_meta( $vid, $cost );
				}
				$made_labels[] = array(
					'name'    => $name,
					'variant' => implode( ' / ', $labelbits ),
					'price'   => (float) $vprice,
					'sku'     => $sku,
					'stock'   => $vstock,
				);
				++$count;
			}

			if ( class_exists( 'WC_Product_Variable' ) ) {
				WC_Product_Variable::sync( $pid );
			}
			$parent_obj = wc_get_product( $pid );
			if ( $parent_obj ) {
				$parent_dirty = false;
				if ( $shared_stock ) {
					$parent_obj->set_manage_stock( true );
					$parent_obj->set_stock_quantity( $pool_qty );
					if ( null !== $f['backorders'] ) {
						$parent_obj->set_backorders( $f['backorders'] );
					}
					$parent_obj->set_stock_status( self::stock_status_for( $pool_qty, $f['backorders'] ) );
					$parent_dirty = true;
				} elseif ( $manage_stock && true === $parent_obj->get_manage_stock() ) {
					// Per-variation inventory belongs ONLY to the variations. A second quantity
					// on the parent becomes stale after normal orders (Woo decrements the child)
					// and can be mistaken for another stock pool.
					$parent_obj->set_manage_stock( false );
					$parent_obj->set_stock_quantity( null );
					$parent_dirty = true;
				}
				if ( $parent_dirty ) {
					$parent_obj->save();
				}
			}
			update_post_meta( $pid, '_erenqvp_stock_mode', $shared_stock ? 'shared' : ( $manage_stock ? 'per_variation' : 'unmanaged' ) );
			self::resync_parent_prices( $pid );
			if ( '' !== $cost ) {
				self::save_cost_meta( $pid, $cost );
			}
			if ( $brand_id ) {
				wp_set_object_terms( $pid, array( $brand_id ), 'product_brand' );
			}
			wc_delete_product_transients( $pid );
			self::refresh_attribute_lookup( $pid );
			$made_id = $pid;

			return array(
				'ok'            => true,
				'product_id'    => $pid,
				'type'            => 'variable',
				'variations'      => $count,
				'sku_conflicts'   => $sku_conflicts,
				'sale_dropped'    => $sale_dropped,
				'rows_unresolved' => $rows_unresolved,
				'edit'          => admin_url( 'post.php?post=' . $pid . '&action=edit' ),
				// Per-row label data (name/variant/price/sku/stock). QVP itself no longer
				// prints labels — this rides the qvp:created event as the integration seam
				// for the standalone label-printing companion plugin.
				'labels'        => $made_labels,
			);
		} finally {
			self::unmute_post_hooks();
			foreach ( $made_ids as $variation_id ) {
				// Variation-aware integrations (ACF, multilingual plugins, indexers) need
				// the same completed-object signal as the parent. Each replay is isolated
				// by refire_integration_hooks(), so a broken callback cannot abort the rest.
				self::refire_integration_hooks( $variation_id );
			}
			if ( $made_id ) {
				// Hooks are restored; let well-behaved integrations (SEO, translation, indexers)
				// react once to the finished product they were shielded from during the build.
				self::refire_integration_hooks( $made_id );
			}
		}
	}

	/**
	 * Re-fire the core post hooks that were muted during creation, once, for the finished parent
	 * product — so legitimate integrations (Yoast/RankMath, WPML/Polylang, ACF, search indexers,
	 * cache purge) can index it. Wrapped so a broken third-party callback still cannot break us:
	 * the product is already fully saved by this point.
	 *
	 * @param int  $product_id Finished product or variation id.
	 * @param bool $update     Whether the post existed before this operation.
	 * @return void
	 */
	public static function refire_integration_hooks( $product_id, $update = false ) {
		$post = get_post( $product_id );
		if ( ! $post ) {
			return;
		}
		try {
			do_action( 'save_post_' . $post->post_type, $product_id, $post, (bool) $update );
			do_action( 'save_post', $product_id, $post, (bool) $update );
			// Core signature: ( int $post_id, WP_Post $post, bool $update, WP_Post|null $post_before ).
			do_action( 'wp_after_insert_post', $product_id, $post, (bool) $update, null );
		} catch ( \Throwable $e ) {
			// Product is already complete; a broken integration hook must not surface here.
		}
	}

	/**
	 * Keep only IDs that point at a real image attachment. The WordPress media library is
	 * shared, so — exactly like WooCommerce core when it assigns a product image — we do NOT
	 * require per-attachment edit rights (the caller already holds product-edit capability).
	 * We do, however, reject anything that is not an actual image attachment, so a stray or
	 * non-image post id can never be stored as a product/variation image.
	 *
	 * @param array $ids List of candidate attachment ids.
	 * @return int[] Filtered, re-indexed list of valid image-attachment ids.
	 */
	private static function valid_image_ids( array $ids ) {
		return array_values( array_filter( array_map( array( __CLASS__, 'valid_image_id' ), $ids ) ) );
	}

	/**
	 * Validate a single image-attachment id (see valid_image_ids()).
	 *
	 * @param mixed $id Candidate attachment id.
	 * @return int The id when it is a real image attachment, otherwise 0.
	 */
	private static function valid_image_id( $id ) {
		$id = absint( $id );
		return ( $id && wp_attachment_is_image( $id ) ) ? $id : 0;
	}

	/**
	 * Refresh a variable parent's legacy price meta (_price, _min/_max_variation_*) from its children.
	 * WC::sync() usually handles this, but we recompute defensively after our direct writes.
	 *
	 * @param int $parent_id Parent product id.
	 * @return void
	 */
	private static function resync_parent_prices( $parent_id ) {
		$p = wc_get_product( $parent_id );
		if ( ! $p || ! $p->is_type( 'variable' ) ) {
			return;
		}
		$regs    = array();
		$actives = array();
		$sales   = array();
		foreach ( $p->get_children() as $vid ) {
			$v = wc_get_product( $vid );
			if ( ! $v ) {
				continue;
			}
			$r = (float) $v->get_regular_price( 'edit' );
			if ( $r > 0 ) {
				$regs[] = $r;
			}
			$a = (float) $v->get_price( 'edit' );
			if ( $a > 0 ) {
				$actives[] = $a;
			}
			$s = $v->get_sale_price( 'edit' );
			if ( '' !== $s && null !== $s && (float) $s > 0 ) {
				$sales[] = (float) $s;
			}
		}
		if ( $actives ) {
			update_post_meta( $parent_id, '_price', min( $actives ) );
			update_post_meta( $parent_id, '_min_variation_price', min( $actives ) );
			update_post_meta( $parent_id, '_max_variation_price', max( $actives ) );
		}
		if ( $regs ) {
			update_post_meta( $parent_id, '_min_variation_regular_price', min( $regs ) );
			update_post_meta( $parent_id, '_max_variation_regular_price', max( $regs ) );
		}
		if ( $sales ) {
			update_post_meta( $parent_id, '_min_variation_sale_price', min( $sales ) );
			update_post_meta( $parent_id, '_max_variation_sale_price', max( $sales ) );
		} else {
			delete_post_meta( $parent_id, '_min_variation_sale_price' );
			delete_post_meta( $parent_id, '_max_variation_sale_price' );
		}
	}

	/**
	 * Apply cost via WooCommerce's native Cost of Goods Sold API when that feature is enabled.
	 * Variations override the parent so each one carries the entered cost. No-op when COGS is off
	 * (the portable meta written by save_cost_meta() still preserves the value).
	 *
	 * @param WC_Product $obj  Product or variation object (modified in place, caller saves).
	 * @param string|float $cost Cost value.
	 * @return void
	 */
	/**
	 * Whether WooCommerce's native Cost of Goods Sold feature is ENABLED (not just present).
	 * Calling the native COGS accessors while the feature is off makes WC fire
	 * wc_doing_it_wrong on every call and return dummy data, so every native call is
	 * gated here. The portable meta written by save_cost_meta() carries the value either way.
	 *
	 * @return bool
	 */
	public static function cogs_native_enabled() {
		return class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class )
			&& method_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class, 'feature_is_enabled' )
			&& \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
	}

	public static function apply_cogs( $obj, $cost ) { // public: the Pro bulk editor reuses it
		$cost = (float) $cost;
		if ( $cost <= 0 || ! is_object( $obj ) || ! method_exists( $obj, 'set_cogs_value' ) || ! self::cogs_native_enabled() ) {
			return;
		}
		$obj->set_cogs_value( $cost );
		// A variation's native cost must be the authoritative per-variation figure the user set, not a
		// delta added on top of the parent's cost. WooCommerce expresses that with the "is additive"
		// flag (there is no set_cogs_value_overrides_parent()); force it off so the set value stands alone.
		if ( $obj->is_type( 'variation' ) && method_exists( $obj, 'set_cogs_value_is_additive' ) ) {
			$obj->set_cogs_value_is_additive( false );
		}
	}


	/**
	 * Whether the "WooCommerce Cost of Goods" extension is active. Its `_wc_cog_cost`
	 * meta key belongs to THAT plugin, so we only ever touch it as interoperability
	 * while the owner is present — never unconditionally (wp.org prefix rule).
	 *
	 * @return bool
	 */
	public static function cog_plugin_active() { // public: the Pro bulk editor applies the same rule
		return class_exists( 'WC_COG' ) || function_exists( 'wc_cog' );
	}

	/**
	 * Store cost in our own namespaced key, plus the Cost of Goods extension's key
	 * when that plugin is active (so its reports pick the value up immediately).
	 * A generic `_cost` key is deliberately never written or deleted because another extension may own it.
	 *
	 * @param int          $post_id Product/variation id.
	 * @param string|float $cost    Cost value.
	 * @return void
	 */
	public static function save_cost_meta( $post_id, $cost ) { // public: the Pro bulk editor reuses it
		$cost = (float) $cost;
		if ( $post_id && $cost > 0 ) {
			if ( self::cog_plugin_active() ) {
				update_post_meta( (int) $post_id, '_wc_cog_cost', $cost );
			}
			update_post_meta( (int) $post_id, '_erenqvp_cost', $cost );
		} elseif ( $post_id ) {
			if ( self::cog_plugin_active() ) {
				delete_post_meta( (int) $post_id, '_wc_cog_cost' );
			}
			delete_post_meta( (int) $post_id, '_erenqvp_cost' );
		}
	}


	/**
	 * Explicitly (re)index the product in WooCommerce's product-attributes lookup table used by
	 * layered navigation / attribute filters. WC normally does this on its own save actions, but we
	 * trigger it directly so filters see the product immediately. No-op when the lookup feature is
	 * disabled (the store method returns early), so this is always safe to call.
	 *
	 * @param int $product_id Parent product id.
	 * @return void
	 */
	public static function refresh_attribute_lookup( $product_id ) { // public: the Pro engine reuses it
		$cls = 'Automattic\\WooCommerce\\Internal\\ProductAttributesLookup\\LookupDataStore';
		if ( ! class_exists( $cls ) || ! function_exists( 'wc_get_container' ) ) {
			return;
		}
		try {
			$store   = wc_get_container()->get( $cls );
			$product = wc_get_product( $product_id );
			if ( $product && method_exists( $store, 'create_data_for_product' ) ) {
				$store->create_data_for_product( $product );
			}
		} catch ( \Throwable $e ) {
			// Lookup indexing is an optimization; failure must not break product creation.
		}
	}

	/**
	 * Apply the optional tax / shipping / backorder fields to a product or parent object.
	 * Each value is skipped when null/blank so existing/inherited values aren't clobbered.
	 *
	 * @param WC_Product $product Product object (modified in place; caller saves).
	 * @param array      $f       Sanitized field bag from create_product().
	 * @return void
	 */
	private static function apply_common_fields( $product, array $f ) {
		if ( null !== $f['tax_status'] ) {
			$product->set_tax_status( $f['tax_status'] );
		}
		if ( null !== $f['tax_class'] ) {
			$product->set_tax_class( $f['tax_class'] );
		}
		if ( null !== $f['backorders'] ) {
			$product->set_backorders( $f['backorders'] );
		}
		if ( $f['shipping_class_id'] > 0 ) {
			$product->set_shipping_class_id( $f['shipping_class_id'] );
		}
		if ( '' !== $f['weight'] ) {
			$product->set_weight( $f['weight'] );
		}
		if ( '' !== $f['length'] ) {
			$product->set_length( $f['length'] );
		}
		if ( '' !== $f['width'] ) {
			$product->set_width( $f['width'] );
		}
		if ( '' !== $f['height'] ) {
			$product->set_height( $f['height'] );
		}
	}

	/**
	 * Stock status given a quantity and the backorder setting.
	 *
	 * @param int         $qty        Stock quantity.
	 * @param string|null $backorders 'no'|'notify'|'yes'|null.
	 * @return string instock|onbackorder|outofstock
	 */
	public static function stock_status_for( $qty, $backorders ) { // public: the Pro engine reuses it
		if ( $qty > 0 ) {
			return 'instock';
		}
		return ( null !== $backorders && 'no' !== $backorders ) ? 'onbackorder' : 'outofstock';
	}

	/**
	 * Recursively sanitize a decoded create-product payload, as early as possible.
	 *
	 * Every string is cleaned with sanitize_text_field(); the two rich-text fields
	 * (description, short_description) keep safe HTML via wp_kses_post(); numbers,
	 * booleans and null are preserved as-is. create_product() still applies the
	 * strict per-field rules (absint/floatval/allow-lists) afterwards — this pass
	 * guarantees that EVERY value, including the raw variation rows later handed to
	 * the `erenqvp_variation_saved` action, is sanitized before any of it is used.
	 *
	 * @param mixed $data Decoded payload array, or a scalar leaf during recursion.
	 * @return mixed Sanitized data of the same shape.
	 */
	public static function sanitize_create_payload( $data ) {
		$html_keys = array( 'description', 'short_description' );
		if ( is_array( $data ) ) {
			$clean = array();
			foreach ( $data as $key => $value ) {
				$key = is_string( $key ) ? sanitize_text_field( $key ) : $key;
				if ( is_array( $value ) ) {
					$clean[ $key ] = self::sanitize_create_payload( $value );
				} elseif ( is_string( $value ) ) {
					$clean[ $key ] = in_array( $key, $html_keys, true ) ? wp_kses_post( $value ) : sanitize_text_field( $value );
				} else {
					$clean[ $key ] = $value; // int, float, bool, null preserved.
				}
			}
			return $clean;
		}
		return is_string( $data ) ? sanitize_text_field( $data ) : $data;
	}

	/**
	 * @param mixed $v Raw tax status.
	 * @return string|null One of taxable|shipping|none, or null to leave default.
	 */
	private static function sanitize_tax_status( $v ) {
		$v = is_string( $v ) ? sanitize_text_field( $v ) : '';
		return in_array( $v, array( 'taxable', 'shipping', 'none' ), true ) ? $v : null;
	}

	/**
	 * @param mixed $v Raw tax class (slug; '' = Standard).
	 * @return string|null Valid slug, or null to leave default.
	 */
	private static function sanitize_tax_class( $v ) {
		if ( ! is_string( $v ) ) {
			return null;
		}
		$v     = sanitize_title( $v );
		$valid = array_merge( array( '' ), array_map( 'sanitize_title', WC_Tax::get_tax_class_slugs() ) );
		return in_array( $v, $valid, true ) ? $v : null;
	}

	/**
	 * @param mixed $v Raw backorders setting.
	 * @return string|null One of no|notify|yes, or null to leave default.
	 */
	private static function sanitize_backorders( $v ) {
		$v = is_string( $v ) ? sanitize_text_field( $v ) : '';
		return in_array( $v, array( 'no', 'notify', 'yes' ), true ) ? $v : null;
	}

	/**
	 * @param mixed $v Raw numeric value (may be any JSON-decoded type from the request).
	 * @return string Decimal string, or '' when empty/non-scalar (prevents wc_format_decimal warnings).
	 */
	private static function dec_or_blank( $v ) {
		return ( is_scalar( $v ) && '' !== $v ) ? wc_format_decimal( $v ) : '';
	}

	/* ───────────────────────── robustness: hook muting ───────────────────────── */

	/**
	 * Some third-party themes/plugins register save_post / transition closures that fatal
	 * ("{closure} not found") while a NEW variation is being saved — aborting the variation
	 * loop and leaving the product with missing variations. We temporarily detach those WP
	 * post hooks during product/variation creation and restore them afterwards.
	 *
	 * Safe because WooCommerce's data store writes the variation data (attributes/stock/price)
	 * directly, and we call WC_Product_Variable::sync() at the end.
	 *
	 * @var array|null
	 */
	private static $muted_hooks = null;

	/**
	 * Detach risky WP post hooks.
	 *
	 * @return void
	 */
	public static function mute_post_hooks() {
		global $wp_filter;
		$hooks = array(
			'save_post',
			'save_post_product',
			'save_post_product_variation',
			'wp_insert_post',
			'wp_after_insert_post',
			'edit_post',
			'post_updated',
			'transition_post_status',
			'new_to_publish',
			'draft_to_publish',
			'auto-draft_to_publish',
			'pending_to_publish',
			'publish_product',
			'publish_product_variation',
		);
		self::$muted_hooks = array();
		foreach ( $hooks as $h ) {
			if ( isset( $wp_filter[ $h ] ) ) {
				self::$muted_hooks[ $h ] = $wp_filter[ $h ];
				unset( $wp_filter[ $h ] );
			}
		}
	}

	/**
	 * Restore previously detached hooks.
	 *
	 * @return void
	 */
	public static function unmute_post_hooks() {
		global $wp_filter;
		if ( ! is_array( self::$muted_hooks ) ) {
			return;
		}
		foreach ( self::$muted_hooks as $h => $obj ) {
			// If code registered a NEW callback on this hook while it was detached, fold those
			// callbacks back into the original object instead of discarding them on restore.
			if ( isset( $wp_filter[ $h ] ) && $wp_filter[ $h ] !== $obj && is_a( $wp_filter[ $h ], 'WP_Hook' ) && is_a( $obj, 'WP_Hook' ) ) {
				foreach ( $wp_filter[ $h ]->callbacks as $priority => $callbacks ) {
					foreach ( $callbacks as $cb ) {
						$obj->add_filter( $h, $cb['function'], $priority, $cb['accepted_args'] );
					}
				}
			}
			$wp_filter[ $h ] = $obj;
		}
		self::$muted_hooks = null;
	}
}
