<?php
declare( strict_types=1 );

namespace SplitPay\Integrations\WooCommerce;

// Direct-access guard kept near the top so Plugin Check's Direct_File_Access_Check (which scans only the first 50 lines) detects it ahead of the long file header below.
defined( 'ABSPATH' ) || exit;

/**
 * Universal save-handler-interception compatibility shim.
 *
 * THE PROBLEM
 * -----------
 * Several WC ecosystem plugins intercept the WordPress admin product
 * save flow BEFORE wp_update_post() runs. Their own pipeline writes the
 * WooCommerce core product fields directly and then short-circuits the
 * standard request — so save_post, save_post_product,
 * woocommerce_admin_process_product_object, AND
 * woocommerce_process_product_meta NEVER fire. Every plugin that hooks
 * those actions to persist its own meta (us, Yoast, ACF, RankMath,
 * custom-fields plugins, …) is silently skipped on every such save.
 * The merchant sees the green "Product updated" banner because the
 * intercepting plugin's pipeline succeeds; the plugin meta is just gone.
 *
 * KNOWN INTERCEPTORS (verified or documented to do this):
 *   - WCFM Multivendor / Frontend Manager / Ultimate (sc-7216, profplaza.nl)
 *   - Dokan Multivendor — frontend dashboard product save
 *   - WC Vendors / WC Vendors Pro — frontend dashboard
 *   - MultiVendor X (MVX) / WC Marketplace — frontend dashboard
 *   - YITH WC Multi Vendor — frontend dashboard
 *   - Generic: any plugin that posts to admin-post.php and exits before
 *     WP's normal save chain runs
 *
 * THE FIX (universal, not WCFM-specific)
 * --------------------------------------
 * Hook the WP `shutdown` action (which DOES fire even when the request
 * was short-circuited mid-stack, including after wp_die /
 * wp_safe_redirect). If we recorded that woocommerce_process_product_meta
 * fired normally during this request, no-op. Otherwise, re-emit a
 * custom action `spp_compat_save_product_meta` that the legacy SPP save
 * handler (BSD_SCSP_Main_Plugin::bsd_product_data_fields_save) ALSO
 * listens to, passing the post_id resolved from the POST envelope. The
 * save handler runs against the exact same $_POST it would have under
 * a normal dispatch — same form-presence marker gate, same field reads,
 * same meta writes — so behavior is identical on every site that
 * DOESN'T have an interceptor (because the shutdown branch then no-ops
 * via the markSaved guard) AND on every site that DOES (because
 * shutdown catches what was swallowed).
 *
 * SAFETY ON NON-INTERCEPTING SITES
 * --------------------------------
 * The shim ALWAYS registers. Its dedup is bulletproof:
 *   1. markSaved() runs at PHP_INT_MAX priority on
 *      woocommerce_process_product_meta — records the post_id.
 *   2. maybeSaveOnShutdown() bails immediately if that post_id is in
 *      the saved-in-request map.
 *   3. So on normal sites, the fallback fires, hits the early-return,
 *      and does nothing. Zero behavioral change.
 *
 * False-positive defenses (in case our marker leaks onto a non-product form):
 *   - $_POST['bsd_spp_product_tab_rendered'] presence required
 *   - post_id must resolve to >0
 *   - get_post_type($post_id) must equal 'product'
 *
 * @package SplitPay
 * @since 3.7.5
 */

class WCFMCompat {

	/** @var array<int,true> Post IDs where the normal save chain already ran this request. */
	private static $saved_in_request = [];

	/** @var bool Idempotency guard so shutdown handler runs at most once per request. */
	private static $shutdown_fired = false;

	/**
	 * Register the shim UNCONDITIONALLY. Called from the main plugin file
	 * on plugins_loaded:5 so the markSaved guard is in place BEFORE any
	 * save handler runs at the default priority.
	 *
	 * Pre-3.7.5 this gated on wcfmActive(). That was wrong — Dokan,
	 * WC Vendors, MVX, and any future unknown interceptor have the same
	 * underlying pattern but different detection signatures. The dedup
	 * via markSaved is bulletproof, so we always-register and let the
	 * fallback no-op itself on non-intercepting sites.
	 */
	public static function register(): void {
		// Record that the normal save chain ran for a given post — used as
		// the dedup signal by the shutdown fallback below.
		add_action( 'woocommerce_process_product_meta', [ self::class, 'markSaved' ], PHP_INT_MAX, 1 );

		// Fallback save on request shutdown for any interceptor that
		// swallowed the normal save chain (WCFM / Dokan / WCV / MVX / …).
		// Priority 0 = early in shutdown to run before any "shutdown cleanup"
		// handlers that might tear down WP state we need (e.g. $wpdb).
		add_action( 'shutdown', [ self::class, 'maybeSaveOnShutdown' ], 0 );

		// REST API save coverage. The WC REST endpoint /wc/v3/products/{id}
		// runs woocommerce_rest_insert_product but does NOT run
		// woocommerce_process_product_meta — that hook only fires from the
		// classic admin form pipeline. Hook the REST insert + variation
		// insert actions to re-emit our save action if the REST payload
		// carries SPP fields. Covers: block editor (uses REST), WP-CLI
		// (`wp wc product update`), mobile apps, headless storefronts,
		// third-party admin tools.
		add_action( 'woocommerce_rest_insert_product_object', [ self::class, 'maybeSaveOnRestInsertProduct' ], 10, 3 );
		add_action( 'woocommerce_rest_insert_product_variation_object', [ self::class, 'maybeSaveOnRestInsertProduct' ], 10, 3 );
	}

	/**
	 * REST-API save-chain bridge.
	 *
	 * Fires from woocommerce_rest_insert_product_object after WC has
	 * already persisted the core product fields. If the REST request
	 * body carries any SPP meta-input keys, we re-emit the legacy save
	 * action with the saved object's ID so SPP's save handler runs
	 * against the REST-supplied data.
	 *
	 * Idempotent: dedups against the same `saved_in_request` map as the
	 * shutdown fallback. If the request also triggered the classic
	 * save chain (unusual but possible), this no-ops.
	 *
	 * @param mixed              $product  WC_Product after save.
	 * @param \WP_REST_Request   $request  Incoming REST request.
	 * @param bool               $creating True if this was an insert vs update.
	 */
	public static function maybeSaveOnRestInsertProduct( $product, $request, $creating ): void {
		if ( ! is_object( $product ) || ! method_exists( $product, 'get_id' ) ) {
			return;
		}
		$post_id = (int) $product->get_id();
		if ( $post_id <= 0 ) {
			return;
		}

		// Dedup: if the classic save chain already ran for this product
		// during this request, no-op.
		if ( ! empty( self::$saved_in_request[ $post_id ] ) ) {
			return;
		}

		// Only re-emit if the REST payload actually carries SPP fields.
		// REST clients can include them as a meta_data array OR as
		// top-level body keys (depending on whether they go through
		// /wc/v3 or /wp/v2). Check both shapes for any spp_*/bsd_spp_*.
		$has_spp_payload = false;
		if ( method_exists( $request, 'get_json_params' ) ) {
			$body = $request->get_json_params();
			if ( is_array( $body ) ) {
				foreach ( array_keys( $body ) as $k ) {
					if ( is_string( $k ) && ( strpos( $k, 'bsd_spp_' ) === 0 || strpos( $k, 'spp_' ) === 0 ) ) {
						$has_spp_payload = true;
						break;
					}
				}
				if ( ! $has_spp_payload && isset( $body['meta_data'] ) && is_array( $body['meta_data'] ) ) {
					foreach ( $body['meta_data'] as $row ) {
						$key = is_array( $row ) ? ( $row['key'] ?? '' ) : ( is_object( $row ) ? ( $row->key ?? '' ) : '' );
						if ( is_string( $key ) && ( strpos( $key, 'bsd_spp_' ) === 0 || strpos( $key, 'spp_' ) === 0 ) ) {
							$has_spp_payload = true;
							break;
						}
					}
				}
			}
		}

		if ( ! $has_spp_payload ) {
			return;
		}

		do_action( 'spp_compat_save_product_meta', $post_id );
		self::$saved_in_request[ $post_id ] = true;
	}

	/**
	 * Detection helper — true when ANY known intercepting marketplace
	 * plugin is active. Kept public for diagnostics (the Site Health card
	 * + the save-trace tooling both surface this), but no longer gates
	 * the shim's runtime behavior.
	 *
	 * Renamed semantically: was wcfmActive, now interceptingMarketplaceActive.
	 * Old name preserved as alias for back-compat with diagnostic tooling.
	 */
	public static function interceptingMarketplaceActive(): bool {
		return // WCFM family
			defined( 'WCFM_VERSION' )
			|| defined( 'WCFMmp_VERSION' )
			|| class_exists( 'WCFM', false )
			|| class_exists( 'WCFMmp', false )
			|| class_exists( 'WCFMu', false )
			// Dokan
			|| defined( 'DOKAN_PLUGIN_VERSION' )
			|| defined( 'WEDOCS_DOKAN_PLUGIN_VERSION' )
			|| class_exists( 'WeDevs_Dokan', false )
			// WC Vendors / WC Vendors Pro
			|| defined( 'WCV_VERSION' )
			|| defined( 'WCV_PRO_VERSION' )
			|| class_exists( 'WC_Vendors', false )
			// MultiVendor X / WC Marketplace
			|| defined( 'MVX_PLUGIN_VERSION' )
			|| defined( 'MVX_VERSION' )
			|| class_exists( 'MVX', false )
			// YITH WC Multi Vendor
			|| defined( 'YITH_WPV_VERSION' )
			|| class_exists( 'YITH_Vendors', false );
	}

	/**
	 * @deprecated 3.7.5 use interceptingMarketplaceActive(). Kept for
	 * back-compat with diagnostic tooling that probed this before the
	 * universal-protection rewrite.
	 */
	public static function wcfmActive(): bool {
		return self::interceptingMarketplaceActive();
	}

	/**
	 * Fires from woocommerce_process_product_meta at PHP_INT_MAX so we
	 * record completion AFTER every other registered save handler has run.
	 * The shutdown fallback then consults this map to decide whether to
	 * re-fire the save chain.
	 *
	 * @param int|string $post_id Coerced to int — WP sometimes passes string IDs.
	 */
	public static function markSaved( $post_id ): void {
		$pid = (int) $post_id;
		if ( $pid > 0 ) {
			self::$saved_in_request[ $pid ] = true;
		}
	}

	/**
	 * Shutdown fallback. Engages only when:
	 *   1) the POST envelope carries the SPP form-presence marker
	 *      (so we know the SPP tab was actually rendered on the form
	 *      that submitted — same gate the save handler itself uses),
	 *   2) we can resolve a post_id from the POST envelope,
	 *   3) markSaved() was NOT called for that post_id this request
	 *      (meaning the normal save chain did NOT run — i.e. WCFM or
	 *      a similar interceptor swallowed it),
	 *   4) the resolved post_id is actually a product.
	 *
	 * On a non-WCFM site this method still runs every shutdown but
	 * always short-circuits at condition (3) because the normal save
	 * chain DOES fire there.
	 */
	public static function maybeSaveOnShutdown(): void {
		if ( self::$shutdown_fired ) {
			return;
		}
		self::$shutdown_fired = true;

		// Marker absent → SPP tab was not on this form OR this isn't a save POST at all.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		if ( empty( $_POST ) || empty( $_POST['bsd_spp_product_tab_rendered'] ) ) {
			return;
		}

		// Resolve post_id from the common WP/WCFM admin save envelopes.
		$post_id = 0;
		foreach ( [ 'post_ID', 'ID', 'post_id', 'product_id' ] as $k ) {
			if ( isset( $_POST[ $k ] ) ) {
				$post_id = (int) $_POST[ $k ];
				if ( $post_id > 0 ) {
					break;
				}
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( $post_id <= 0 ) {
			return;
		}

		// Normal save already ran → no-op (non-WCFM sites land here).
		if ( ! empty( self::$saved_in_request[ $post_id ] ) ) {
			return;
		}

		// did_action() back-stop in case some non-standard dispatch fired the
		// action without our markSaved listener catching it (e.g. priority
		// race during late plugin loads).
		if ( did_action( 'woocommerce_process_product_meta' ) > 0 ) {
			$fired_for = self::$saved_in_request;
			if ( isset( $fired_for[ $post_id ] ) ) {
				return;
			}
		}

		// Confirm the resolved id is actually a product (defense in depth —
		// rules out the rare case where the marker leaks onto a non-product
		// form via a plugin embedding our partial somewhere else).
		if ( function_exists( 'get_post_type' ) && get_post_type( $post_id ) !== 'product' ) {
			return;
		}

		// Re-emit the save action. BSD_SCSP_Main_Plugin::bsd_product_data_fields_save
		// is bound to BOTH woocommerce_process_product_meta AND this custom
		// action (see includes/class-bsd-split-pay-stripe-connect-woo.php),
		// so the SAME save method runs in BOTH dispatch paths with the SAME
		// $_POST. Marketplace customers get persistent SPP meta; non-WCFM
		// sites are unaffected because they hit the early-return above.
		do_action( 'spp_compat_save_product_meta', $post_id );

		// Mark saved so a follow-up shutdown handler (shouldn't happen, but
		// belt-and-suspenders) doesn't double-fire.
		self::$saved_in_request[ $post_id ] = true;
	}
}
