<?php
/**
 * PS_Label_Installation — installation-scoped ownership guard for label meta.
 *
 * Problem it solves:
 *   A fresh install of the plugin must NEVER surface label data (paid state,
 *   download links, tracking numbers, session_ids) that belong to a different
 *   installation. Previously, if a DB carried _ps_label_* order meta from a
 *   prior install (e.g. site export/import, plugin uninstall+reinstall, DB
 *   migration from another shop), the metabox happily rendered the old label
 *   as "completed" and the download button proxied a PDF from the original
 *   shop's VPS session.
 *
 * Strategy:
 *   - Every write to _ps_label_status stamps the current parcselect_installation_id
 *     on the order as _ps_label_installation_id.
 *   - Every read path (metabox render, download AJAX) verifies ownership and
 *     purges stale meta on mismatch.
 *   - On plugin activation, if the stored sweep-stamp does not match the
 *     current installation_id, all _ps_label_* postmeta is purged bulk.
 *
 * @package ParcelSelector
 */

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

class Parcselect_Label_Installation {

	/** Order meta key storing the installation_id that generated the label. */
	const META_KEY = '_ps_label_installation_id';

	/** Site option holding the installation_id at time of last sweep. */
	const SWEEP_STAMP_OPT = 'parcselect_label_sweep_installation_id';

	/** Prefix used by every label order meta key. */
	const META_PREFIX = '_ps_label_';

	/**
	 * Current installation_id. Empty string when the plugin has not enrolled yet.
	 */
	public static function current() {
		return (string) get_option( 'parcselect_installation_id', '' );
	}

	/**
	 * Per-order stamp marker — includes installation_id. Used for owns()
	 * checks on individual orders. When the iid changes (e.g. re-enrollment,
	 * fresh install, another shop's DB), old order meta is detected as
	 * foreign and purged on first render / AJAX hit.
	 */
	public static function identity_marker() {
		$iid  = self::current();
		$home = (string) get_option( 'home', '' );
		$site = (string) get_option( 'siteurl', '' );
		return 'v2:' . wp_hash( $iid . '|' . $home . '|' . $site );
	}

	/**
	 * Bulk-sweep trigger marker — URLs only, NOT installation_id. This keeps
	 * the sweep from firing on first enrollment (when iid transitions from
	 * "" to a real value); per-order ownership checks still detect the iid
	 * mismatch and clean up foreign orders lazily on read.
	 *
	 * Using raw options (NOT home_url()/site_url()) to avoid WPML / Polylang
	 * / cache plugin filters that rewrite these per request.
	 */
	protected static function sweep_trigger_marker() {
		$home = (string) get_option( 'home', '' );
		$site = (string) get_option( 'siteurl', '' );
		return 'sweep1:' . wp_hash( $home . '|' . $site );
	}

	/**
	 * Stamp the current installation_id on an order. Call this immediately
	 * after any write that creates / advances _ps_label_status.
	 *
	 * @param WC_Order|int $order_or_id Order object or id.
	 */
	public static function stamp( $order_or_id ) {
		$order = ( $order_or_id instanceof WC_Order ) ? $order_or_id : wc_get_order( $order_or_id );
		if ( ! $order ) {
			return;
		}
		// Stamp the composite identity (installation_id + URLs hash). This
		// way a future site clone that copies the order meta is still
		// detected as foreign on per-order owns() checks, not just by the
		// activation-time bulk sweep.
		$order->update_meta_data( self::META_KEY, self::identity_marker() );
		$order->save();
	}

	/**
	 * True when the order's label meta was written by this installation.
	 * False when the order carries label meta from another install (cross-
	 * install leak) or is missing the installation stamp entirely.
	 *
	 * @param WC_Order $order
	 */
	public static function owns( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return false;
		}
		$stamp = (string) $order->get_meta( self::META_KEY, true );
		if ( '' === $stamp ) {
			return false;
		}
		// Compare against the composite identity marker (installation_id +
		// URLs). Catches clones, re-enrollments and unstamped legacy meta.
		return hash_equals( self::identity_marker(), $stamp );
	}

	/**
	 * True when the order carries ANY _ps_label_* meta — used to detect
	 * leftover data from a previous plugin install on the same DB.
	 *
	 * @param WC_Order $order
	 */
	public static function has_any_label_meta( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return false;
		}
		// Common keys — cheap check without querying all meta.
		$probe = array(
			'_ps_label_status',
			'_ps_label_url',
			'_ps_label_label_file',
			'_ps_label_session_id',
			'_ps_label_tracking_number',
			'_ps_label_local_pdf_path',
			'_ps_label_paid_at',
			'_ps_label_completed_at',
			'_ps_label_txid',
			'_ps_label_consent',
			'_ps_label_sender_confirmed',
			'_ps_label_history',
			// Legacy bare-prefix alias — if any install wrote it, treat
			// presence alone as enough to trigger a purge on render.
			'_ps_tracking_number',
		);
		foreach ( $probe as $k ) {
			if ( '' !== (string) $order->get_meta( $k, true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Wipe every _ps_label_* meta key from a single order. Used when a
	 * cross-install leak is detected on render / download.
	 *
	 * @param WC_Order $order
	 * @return int Number of keys deleted.
	 */
	public static function purge_order( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return 0;
		}
		$keys = array_merge( self::known_meta_keys(), self::legacy_alias_keys() );
		$deleted = 0;
		foreach ( $keys as $k ) {
			if ( '' !== (string) $order->get_meta( $k, true ) || null !== $order->get_meta( $k, true ) ) {
				$order->delete_meta_data( $k );
				$deleted++;
			}
		}
		// v4.4.5 M3 §3.6 — skasuj rekord PDF z tabeli DB (zawiera bajty PDF
		// z danymi adresowymi). Cross-install leak: rekord innej instalacji
		// nie może zostać osierocony w tabeli. delete_db_pdf() jest no-op gdy
		// brak rekordu, więc wywołanie jest bezpieczne niezależnie od meta.
		if ( class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			$_pdf_db_deleted = Parcselect_Label_Pdf_Store::delete_db_pdf( $order->get_id() );
			if ( $_pdf_db_deleted > 0 ) {
				$deleted++;
			}
		}
		if ( $deleted > 0 ) {
			$order->save();
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::warn(
					$order->get_id(),
					'installation.stale_meta_purged',
					array(
						'keys_removed'   => $deleted,
						'reason'         => 'cross_install_detected',
						'current_iid'    => substr( self::current(), 0, 12 ) . '…',
					)
				);
			}
			$order->add_order_note(
				__( 'Label tool: label data from another plugin installation was detected — cleared (security reason).', 'parcel-selector' )
			);
		}
		return $deleted;
	}

	/**
	 * Known _ps_label_* order meta keys the plugin ever writes. Single
	 * source of truth for both purge_order() and bulk_sweep().
	 *
	 * @return string[]
	 */
	public static function known_meta_keys() {
		return array(
			'_ps_label_status',
			'_ps_label_session_id',
			'_ps_label_url',
			'_ps_label_label_file',
			'_ps_label_local_pdf_path',
			'_ps_label_local_pdf_url',
			'_ps_label_tracking_number',
			'_ps_label_carrier',
			'_ps_label_completed_at',
			'_ps_label_paid_at',
			'_ps_label_created_at',
			'_ps_label_txid',
			'_ps_label_crypto',
			'_ps_label_crypto_amount',
			'_ps_label_price_pln',
			'_ps_label_price_display',
			'_ps_label_base_price',
			'_ps_label_sol_amount',
			'_ps_label_sol_rate',
			'_ps_label_sol_wallet',
			'_ps_label_qr_uri',
			'_ps_label_size',
			'_ps_label_weight',
			'_ps_label_package_id',
			'_ps_label_shipping provider_id',
			'_ps_label_history',
			'_ps_label_error',
			'_ps_label_retry_count',
			'_ps_label_debug_log',
			'_ps_label_consent',
			'_ps_label_consent_date',
			'_ps_label_consent_user',
			'_ps_label_sender_confirmed',
			'_ps_label_sender_confirmed_date',
			'_ps_label_installation_id',
			// v4.4.5 M3 §3.6 — ID rekordu PDF w tabeli DB (Parcselect_Label_Pdf_Store).
			'_ps_label_pdf_db_id',
		);
	}

	/**
	 * Bare-prefix legacy aliases the plugin has historically written as
	 * mirrors of the canonical `_ps_label_*` keys. These live OUTSIDE the
	 * `_ps_label_` namespace, so a pattern-based sweep misses them and a
	 * cross-install leak can leave the alias behind even after the
	 * canonical key is purged. Keep this list tight — only keys the plugin
	 * itself wrote; do NOT include checkout-plugin locker metadata
	 * (`_ps_locker_*`) which is legitimate per-order data unrelated to the
	 * label lifecycle.
	 *
	 * @return string[]
	 */
	public static function legacy_alias_keys() {
		return array(
			'_ps_tracking_number',
		);
	}

	/**
	 * Run once per activation (and whenever installation_id changes):
	 * wipe every order of its _ps_label_* meta so a freshly activated
	 * plugin can never surface data it did not generate itself.
	 *
	 * Uses direct DB queries — WC_Order loops on large stores are
	 * prohibitively slow and this runs on activation, off the hot path.
	 */
	public static function bulk_sweep_if_installation_changed() {
		// Per-request static cache — admin_init fires on every admin page
		// request (often 150-300× per day on an active site); the option
		// lookup is autoloaded but still costs a cache hit per call. Short
		// circuit after the first invocation within the same PHP request.
		static $checked_this_request = null;
		if ( null !== $checked_this_request ) {
			return $checked_this_request;
		}

		// Sweep fires only when URLs change — NOT when installation_id
		// changes. That keeps first-enrollment (iid "" → real value) from
		// deleting the secrets enrollment just wrote. Per-order owns()
		// checks still handle iid drift lazily on read.
		$current_marker = self::sweep_trigger_marker();
		$last           = (string) get_option( self::SWEEP_STAMP_OPT, '' );

		// No URL change → nothing to do (cheap short-circuit on every admin_init).
		if ( $current_marker === $last ) {
			$checked_this_request = array( 'swept' => false, 'reason' => 'urls_unchanged' );
			return $checked_this_request;
		}

		global $wpdb;
		$prefix_like = $wpdb->esc_like( self::META_PREFIX ) . '%';
		$deleted_classic = 0;
		$deleted_hpos    = 0;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- one-shot activation sweep; WC API too slow on bulk purge; nazwa tabeli HPOS z $wpdb->prefix (zaufana), wartosci przez $wpdb->prepare().
		// 1. Classic CPT post meta (shop_order posts).
		$deleted_classic = (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE pm FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE p.post_type = 'shop_order' AND pm.meta_key LIKE %s",
				$prefix_like
			)
		);

		// 2. HPOS orders meta (WooCommerce >= 8.x custom order tables).
		$hpos_table = $wpdb->prefix . 'wc_orders_meta';
		$table_exists = $wpdb->get_var(
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $hpos_table )
		);
		if ( $table_exists ) {
			$deleted_hpos = (int) $wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$hpos_table} WHERE meta_key LIKE %s",
					$prefix_like
				)
			);
		}

		// 3. Legacy alias keys outside the `_ps_label_` prefix. Pattern
		// sweep above cannot catch these; identity change must wipe them
		// along with the canonical meta or the alias survives and gets
		// rendered by the orders column / shipment-tracking integrations.
		foreach ( self::legacy_alias_keys() as $alias ) {
			$deleted_classic += (int) $wpdb->query(
				$wpdb->prepare(
					"DELETE pm FROM {$wpdb->postmeta} pm
					 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
					 WHERE p.post_type = 'shop_order' AND pm.meta_key = %s",
					$alias
				)
			);
			if ( $table_exists ) {
				$deleted_hpos += (int) $wpdb->query(
					$wpdb->prepare(
						"DELETE FROM {$hpos_table} WHERE meta_key = %s",
						$alias
					)
				);
			}
		}
		// phpcs:enable

		// Scrub per-install options that would otherwise prefill the next
		// label with the previous shop's sender address / wallet / enrolled
		// secrets. These are plugin settings, not order meta, but they're
		// tied 1:1 to the installation identity.
		$stale_install_opts = array(
			'parcselect_label_sender_name',
			'parcselect_label_sender_company',
			'parcselect_label_sender_street',
			'parcselect_label_sender_city',
			'parcselect_label_sender_postcode',
			'parcselect_label_sender_phone',
			'parcselect_label_sender_email',
			'parcselect_label_wallet_sol',
			'parcselect_label_proxy_secret',
			'parcselect_label_webhook_secret',
			'parcselect_label_enrolled_at',
			'parcselect_embed_secret',
			// Drop installation_id too — a URL change means this iid
			// belongs to the previous site. maybe_enroll() will request
			// a fresh one from the VPS against the new site_url.
			'parcselect_installation_id',
		);
		foreach ( $stale_install_opts as $opt ) {
			delete_option( $opt );
		}
		// Transients use a different storage key prefix — delete via the
		// dedicated API so the enroll backoff is cleared and maybe_enroll()
		// re-runs on the next admin_init instead of silently waiting 15min.
		delete_transient( 'parcselect_label_enroll_backoff' );
		delete_transient( 'parcselect_label_enroll_error' );

		// Delete local mirror PDFs from uploads/ps-labels. These are
		// standalone files that survive on disk independent of order meta;
		// a stale one could be served if a new order happens to hash to the
		// same filename. Safer to wipe on identity change.
		$_uploads = function_exists( 'wp_upload_dir' ) ? wp_upload_dir() : array();
		if ( ! empty( $_uploads['basedir'] ) ) {
			$_labels_dir = trailingslashit( $_uploads['basedir'] ) . 'ps-labels';
			if ( is_dir( $_labels_dir ) ) {
				foreach ( (array) glob( $_labels_dir . '/*.pdf' ) as $_pdf ) {
					wp_delete_file( $_pdf );
				}
			}
		}

		// Record the composite identity marker the sweep was completed under
		// so we do not sweep again until installation_id, home_url or
		// site_url changes.
		update_option( self::SWEEP_STAMP_OPT, $current_marker, false );

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::warn(
				null,
				'installation.bulk_sweep',
				array(
					'reason'          => '' === $last ? 'first_activation' : 'identity_changed',
					'previous_marker' => substr( $last, 0, 16 ) . '…',
					'current_marker'  => substr( $current_marker, 0, 16 ) . '…',
					'deleted_classic' => $deleted_classic,
					'deleted_hpos'    => $deleted_hpos,
				)
			);
		}

		$checked_this_request = array(
			'swept'           => true,
			'deleted_classic' => $deleted_classic,
			'deleted_hpos'    => $deleted_hpos,
		);
		return $checked_this_request;
	}

	/**
	 * Debug helper — emit a console.group dump to F12 with FULL state of
	 * the order and the installation guard. Called from every render path
	 * and key AJAX handler. Output is ALWAYS emitted (no debug-flag check)
	 * for v4.0.4-DEBUG-HARDENED — purpose is forensic visibility into
	 * exactly what data the plugin sees, where it came from, and whether
	 * the guard would accept or reject it.
	 *
	 * @param string   $context Where the dump is fired from (e.g. 'embed.render', 'ajax.recheck').
	 * @param WC_Order $order   Order being processed.
	 * @param array    $extra   Optional extra data to include.
	 */
	public static function debug_to_f12( $context, $order, $extra = array() ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return;
		}
		// Only emit in admin context — frontend customer pages never need this.
		if ( ! is_admin() && ! ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
			return;
		}
		$order_id = $order->get_id();

		// Collect every _ps_label_* meta key with its value (truncated for size).
		$all_meta = array();
		foreach ( self::known_meta_keys() as $k ) {
			$v = $order->get_meta( $k, true );
			if ( '' !== (string) $v ) {
				if ( is_array( $v ) ) {
					$all_meta[ $k ] = '<array, ' . count( $v ) . ' items>';
				} else {
					$all_meta[ $k ] = strlen( (string) $v ) > 80
						? substr( (string) $v, 0, 80 ) . '…'
						: (string) $v;
				}
			}
		}
		// Also probe legacy aliases that don't share the prefix.
		foreach ( array( '_ps_tracking_number', '_ps_locker_id', '_ps_locker_name' ) as $k ) {
			$v = $order->get_meta( $k, true );
			if ( '' !== (string) $v ) {
				$all_meta[ '[alias] ' . $k ] = (string) $v;
			}
		}

		$payload = array(
			'context'              => $context,
			'order_id'             => $order_id,
			'order_status'         => $order->get_status(),
			'plugin_version'       => defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '?',
			'embed_file_mtime'     => @filemtime( PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-embed.php' ) ?: 0, // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			'metabox_file_mtime'   => @filemtime( PARCSELECT_PLUGIN_DIR . 'PS_LABEL/class-ps-label-metabox.php' ) ?: 0, // phpcs:ignore
			'installation_class'   => class_exists( 'Parcselect_Label_Installation' ),
			'installation_id_opt'  => substr( (string) self::current(), 0, 16 ) . '…',
			'identity_marker'      => substr( self::identity_marker(), 0, 24 ) . '…',
			'sweep_marker_stored'  => substr( (string) get_option( self::SWEEP_STAMP_OPT, '' ), 0, 24 ) . '…',
			'polling_purge_done'   => (string) get_option( 'parcselect_label_polling_purge_done', '' ),
			'order_stamp'          => substr( (string) $order->get_meta( self::META_KEY, true ), 0, 24 ) . '…',
			'owns_result'          => self::owns( $order ),
			'has_any_label_meta'   => self::has_any_label_meta( $order ),
			'meta_present'         => $all_meta,
			'request_uri'          => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
			'php_memory_used_mb'   => round( memory_get_usage( true ) / 1024 / 1024, 1 ),
			'extra'                => $extra,
		);

		// JSON_HEX_TAG | JSON_HEX_AMP make the encoded payload safe to embed inside a <script> tag.
		$json = wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP );
		$tag  = '[PS-DEBUG ' . sanitize_key( $context ) . ' #' . $order_id . ']';

		// Two emit modes:
		// 1) Standard render context → debug JS injected via wp_add_inline_script.
		// 2) AJAX context → wp.send_json wrapper adds _debug field instead (caller appends).
		if ( ! ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
			$safe_tag  = wp_json_encode( $tag );
			$safe_data = $json; // already wp_json_encode'd by caller
			$debug_js  = '(function(){try{';
			$debug_js .=   'var __ps_dbg = ' . $safe_data . ';';
			$debug_js .=   'console.group(' . $safe_tag . ');';
			$debug_js .=   'console.log("%cORDER STATE","background:#ec4899;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold");';
			$debug_js .=   'console.table(__ps_dbg.meta_present);';
			$debug_js .=   'console.log("%cFULL DUMP","background:#0ea5e9;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold");';
			$debug_js .=   'console.log(__ps_dbg);';
			$debug_js .=   'if(!__ps_dbg.owns_result && __ps_dbg.has_any_label_meta){';
			$debug_js .=     'console.error("%c*** LEAK DETECTED ***","background:#dc2626;color:#fff;padding:4px 10px;border-radius:4px;font-weight:bold;font-size:14px");';
			$debug_js .=     'console.error("Order has label meta but owns()=false. Plugin is REFUSING to render this data. See meta_present table above.");';
			$debug_js .=   '}';
			$debug_js .=   'console.groupEnd();';
			$debug_js .= '}catch(e){console.error("PS-DEBUG inline failed:",e);}})();';
			if ( ! wp_script_is( 'parcselect-label-debug-inline', 'registered' ) ) {
				wp_register_script( 'parcselect-label-debug-inline', '', array(), PARCSELECT_VERSION, true );
			}
			wp_enqueue_script( 'parcselect-label-debug-inline' );
			wp_add_inline_script( 'parcselect-label-debug-inline', $debug_js );
		}

		// Also write to PHP error log so admin can correlate without opening F12.
		if ( function_exists( 'error_log' ) ) {
			error_log( $tag . ' owns=' . ( self::owns( $order ) ? '1' : '0' ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie diagnostyczne bledu
				. ' has_meta=' . ( self::has_any_label_meta( $order ) ? '1' : '0' )
				. ' status=' . $order->get_status()
				. ' uri=' . ( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '-' )
			);
		}
	}

	/**
	 * Hard fail-closed gate. Returns TRUE only when the plugin can
	 * cryptographically guarantee that the order's label data was
	 * generated by THIS installation. In all other cases — including
	 * "order has label meta but no stamp" or "stamp does not match
	 * current identity_marker" — returns FALSE.
	 *
	 * Render code MUST consult this before displaying any label artefact
	 * (tracking number, TXID, PDF link, payment status). When this
	 * returns FALSE, the render code MUST also call purge_order() to
	 * eliminate the stale meta.
	 *
	 * @param WC_Order $order
	 * @return bool
	 */
	public static function safe_to_render( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return false;
		}
		// 1. Must have a stamp.
		$stamp = (string) $order->get_meta( self::META_KEY, true );
		if ( '' === $stamp ) {
			return false;
		}
		// 2. Stamp must match current identity_marker exactly.
		if ( ! hash_equals( self::identity_marker(), $stamp ) ) {
			return false;
		}
		// 3. installation_id must be enrolled (not pre-enrollment placeholder).
		if ( '' === self::current() ) {
			return false;
		}
		return true;
	}

	/**
	 * One-shot forced purge for installations upgrading from < 4.0.4.
	 *
	 * Reason: versions 4.0.0–4.0.3 ran a polling block in
	 * Parcselect_Label_Embed::render() that pulled `(tracking, label_url, txid,
	 * sol_amount)` from the central etykietownik VPS by `(source_site,
	 * source_order)` tuple. Because all installs share the same SOL
	 * wallet, the X21 daemon could match a transaction belonging to
	 * another shop's order, return it, and the plugin would auto-stamp
	 * it as ours via Parcselect_Label_Installation::stamp(). The stamp matched
	 * identity_marker, so subsequent owns() checks would return TRUE
	 * forever — hiding the leak from every other guard.
	 *
	 * Patch #1 (v4.0.3) removed the polling. But existing orders that
	 * already received polled data + a fraudulent stamp before the
	 * upgrade remain visible (orders list column, customer email,
	 * tracking link in My Account). owns() cannot detect them — they
	 * carry a real, current-install stamp.
	 *
	 * The only safe action is to wipe ALL `_ps_label_*` meta from every
	 * order on the upgrade transition, regardless of stamp. Legitimate
	 * orders generated by the new install will be regenerated normally
	 * (the iframe + webhook flow recreates the meta when the admin
	 * reopens the order). Cost of one-time UI reset = acceptable price
	 * for guaranteed leak elimination.
	 *
	 * Idempotent — guarded by the `parcselect_label_polling_purge_done`
	 * option. Once the purge runs successfully, the option holds the
	 * version number that performed it; subsequent calls short-circuit.
	 */
	public static function forced_legacy_purge_if_needed() {
		static $checked_this_request = null;
		if ( null !== $checked_this_request ) {
			return $checked_this_request;
		}

		$marker_opt = 'parcselect_label_polling_purge_done';
		$done_for   = (string) get_option( $marker_opt, '' );

		// Already executed for the version that introduced the polling
		// removal — nothing more to do.
		if ( '4.0.4' === $done_for || version_compare( $done_for, '4.0.4', '>=' ) ) {
			$checked_this_request = array( 'purged' => false, 'reason' => 'already_done', 'done_for' => $done_for );
			return $checked_this_request;
		}

		global $wpdb;
		$prefix_like     = $wpdb->esc_like( self::META_PREFIX ) . '%';
		$deleted_classic = 0;
		$deleted_hpos    = 0;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- one-shot upgrade purge; WC API too slow; nazwa tabeli HPOS z $wpdb->prefix (zaufana), wartosci przez $wpdb->prepare().
		$deleted_classic = (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE pm FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE p.post_type = 'shop_order' AND pm.meta_key LIKE %s",
				$prefix_like
			)
		);
		$hpos_table = $wpdb->prefix . 'wc_orders_meta';
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $hpos_table ) ) ) {
			$deleted_hpos = (int) $wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$hpos_table} WHERE meta_key LIKE %s",
					$prefix_like
				)
			);
		}

		// Also drop legacy alias `_ps_tracking_number` written as a mirror
		// by the same polling block — guard.has_any_label_meta does not
		// detect this key because it lives outside the `_ps_label_` prefix.
		$alias_like = $wpdb->esc_like( '_ps_tracking_number' );
		$wpdb->query(
			$wpdb->prepare(
				"DELETE pm FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE p.post_type = 'shop_order' AND pm.meta_key = %s",
				$alias_like
			)
		);
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $hpos_table ) ) ) {
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$hpos_table} WHERE meta_key = %s",
					$alias_like
				)
			);
		}

		// Wipe object cache so the next get_meta() call hits the empty DB.
		wp_cache_flush();

		// Local PDF mirror — same logic as bulk_sweep_if_installation_changed:
		// stale on-disk PDFs could be served by download AJAX even after
		// meta is purged.
		$_uploads = function_exists( 'wp_upload_dir' ) ? wp_upload_dir() : array();
		if ( ! empty( $_uploads['basedir'] ) ) {
			$_labels_dir = trailingslashit( $_uploads['basedir'] ) . 'ps-labels';
			if ( is_dir( $_labels_dir ) ) {
				foreach ( (array) glob( $_labels_dir . '/*.pdf' ) as $_pdf ) {
					wp_delete_file( $_pdf );
				}
			}
		}
		// phpcs:enable

		update_option( $marker_opt, '4.0.4', false );

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::warn(
				null,
				'installation.forced_legacy_purge',
				array(
					'reason'          => 'upgrade_to_4.0.4_wipes_polling_residue',
					'deleted_classic' => $deleted_classic,
					'deleted_hpos'    => $deleted_hpos,
					'previous_marker' => $done_for,
				)
			);
		}

		$checked_this_request = array(
			'purged'          => true,
			'deleted_classic' => $deleted_classic,
			'deleted_hpos'    => $deleted_hpos,
		);
		return $checked_this_request;
	}

	/**
	 * Convenience: assert ownership, purge on mismatch, return ownership
	 * after possible purge. Use at the top of any read path that would
	 * otherwise trust _ps_label_* meta.
	 *
	 * @param WC_Order $order
	 * @return bool true when the order is safely owned by this install.
	 */
	public static function assert_or_purge( $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			return false;
		}
		if ( self::owns( $order ) ) {
			return true;
		}
		if ( self::has_any_label_meta( $order ) ) {
			self::purge_order( $order );
		}
		return false;
	}
}
