<?php
/**
 * Label Embed — VPS-driven shipping label generation (portowany 1:1 z PRO commit 18cb575).
 *
 * Plugin role: render a single iframe pointing to parcelselector.com,
 * relay postMessage events from the iframe to AJAX handlers, persist
 * tracking data via REST webhook callback.
 *
 * ALL UI lives on the VPS (consent, sender form, payment, PDF preview).
 * The plugin contains NO label generation, payment processing,
 * cryptocurrency, or courier API code.
 *
 * FREE vs PRO diff:
 *   - option keys: `ps_label_sender_*` (FREE legacy, user already has these
 *     populated) instead of `ps_sender_*` (PRO key space).
 *   - nonce name unchanged: `ps_label_consent`.
 *
 * @package ParcelSelector
 * @since   4.0.0
 */

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

// phpcs:disable WordPress.WP.I18n.MissingTranslatorsComment -- Translators comments are placed before sprintf() wrapper, not before each individual __()/_e() call

if ( ! function_exists( 'parcselect_label_cennik' ) ) {
/**
 * CENNIK-FETCH 2026-05-16: cennik gabarytow pobierany z VPS (endpoint CEB),
 * NIE hardkodowany we wtyczce. Zrodlo prawdy jest na serwerze; tu tylko cache
 * 1h do WYSWIETLENIA. Cena platnosci i tak jest EGZEKWOWANA serwerowo przez
 * CEB (ceb_cennik_price). Fallback na stale wartosci gdy VPS nieosiagalny.
 *
 * @return array Tablica array( 'A' => float, 'B' => float, 'C' => float ).
 */
function parcselect_label_cennik() {
	$fallback = array( 'A' => 19.99, 'B' => 22.49, 'C' => 24.99 );
	$cached = get_transient( 'parcselect_label_cennik_v1' );
	if ( is_array( $cached ) && isset( $cached['A'], $cached['B'], $cached['C'] ) ) {
		return $cached;
	}
	$resp = wp_remote_get(
		'https://etykietownik.parcelselector.com/wp-json/ceb/v1/cennik',
		array( 'timeout' => 5 )
	);
	if ( ! is_wp_error( $resp ) && 200 === (int) wp_remote_retrieve_response_code( $resp ) ) {
		$j = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
		if ( is_array( $j ) && isset( $j['gabaryty'] ) && is_array( $j['gabaryty'] )
		     && ! empty( $j['gabaryty']['A'] ) && ! empty( $j['gabaryty']['B'] ) && ! empty( $j['gabaryty']['C'] ) ) {
			$c = array(
				'A' => round( (float) $j['gabaryty']['A'], 2 ),
				'B' => round( (float) $j['gabaryty']['B'], 2 ),
				'C' => round( (float) $j['gabaryty']['C'], 2 ),
			);
			set_transient( 'parcselect_label_cennik_v1', $c, HOUR_IN_SECONDS );
			return $c;
		}
	}
	return $fallback;
}
}

class Parcselect_Label_Embed {

	/** VPS map-server renders the pretty 3-column wizard (Stage 1-3). */
	const EMBED_URL = 'https://map.parcelselector.com/embed/label';
	const IFRAME_ORIGIN = 'https://map.parcelselector.com';

	/**
	 * Bug #13/#14/#15/#16 FIX (2026-05-15) — parse_locker_address helper.
	 * Rozparsowuje string formatu "Partyzantów 3A, 21-450 Stoczek Łukowski" na
	 * komponenty street/postcode/city. Używane do reklasyfikacji danych z
	 * _ps_locker_address1 (pełny adres) zamiast meta _ps_locker_city (które
	 * faktycznie zawiera OPIS lokalu, np. "Przy wejściu") i _ps_locker_postcode
	 * (które zwykle puste). Helper jest pure-static, nie czyta meta — sam
	 * caller decyduje co podstawić.
	 *
	 * @param string $address Raw locker address string.
	 * @return array { 'street' => string, 'postcode' => string, 'city' => string }
	 */
	public static function parse_locker_address( $address ) {
		$out = array( 'street' => '', 'postcode' => '', 'city' => '' );
		if ( ! is_string( $address ) || '' === $address ) {
			return $out;
		}
		// Postcode (PL XX-XXX format anywhere in string).
		if ( preg_match( '/\b(\d{2}-\d{3})\b/', $address, $m ) ) {
			$out['postcode'] = $m[1];
		}
		// Split by comma — typowy format "ULICA, KOD MIASTO" lub tylko "ULICA".
		$parts = array_map( 'trim', explode( ',', $address ) );
		if ( count( $parts ) >= 2 ) {
			$out['street'] = $parts[0];
			$second = $parts[1];
			// Usuń wiodący postcode (np. "21-450 Stoczek Łukowski" → "Stoczek Łukowski").
			$second = preg_replace( '/^\d{2}-\d{3}\s*/', '', $second );
			$out['city'] = trim( $second );
		} elseif ( count( $parts ) === 1 ) {
			$out['street'] = $parts[0];
		}
		return $out;
	}

	/**
	 * Get CEB (central label system) base URL — filterable for self-hosted setups.
	 *
	 * @return string
	 */
	private static function ceb_url() {
		return apply_filters( 'parcselect_ceb_base_url', get_option( 'parcselect_label_ceb_url', class_exists( 'Parcselect_Oracle_Client' ) ? Parcselect_Oracle_Client::base() : 'https://parcelselector.com' ) );
	}

	public static function init() {
		// Priority 1 = before WooCommerce (places metabox above order items).
		// v4.1.2: PS_Label_Metabox przejmuje rendering metabox (priority 5).
		// Disabled to avoid double-rendering. render_from_pscp_payload is now public static
		// and called by Parcselect_Label_Metabox::render_iframe_ui (delegated).
		// add_action( 'add_meta_boxes', array( __CLASS__, 'register_metabox' ), 1, 2 );

		// AJAX endpoints called by the parent JS bridge (in admin_footer).
		add_action( 'wp_ajax_parcselect_label_accept_consent', array( __CLASS__, 'ajax_accept_consent' ) );
		add_action( 'wp_ajax_parcselect_label_save_sender',    array( __CLASS__, 'ajax_save_sender' ) );
		add_action( 'wp_ajax_parcselect_label_accept_receiver', array( __CLASS__, 'ajax_accept_receiver' ) );
		add_action( 'wp_ajax_parcselect_label_save_tracking',  array( __CLASS__, 'ajax_save_tracking' ) );
		// v4.0.13 — plugin-native Stage 4 (SOL payment). Called by bridge_js
		// after iframe emits ps_label_size_selected. Creates session on VPS
		// with UNIQUE dust-lamport amount so VPS X21 cannot match this
		// session against any historical TX (amount won't collide).
		add_action( 'wp_ajax_parcselect_label_create_session', array( __CLASS__, 'ajax_create_session' ) );
		add_action( 'wp_ajax_parcselect_label_check_payment',  array( __CLASS__, 'ajax_check_payment' ) );
		// NS-54 v3 2026-04-30: client-side escalation cascade — kicks Oracle to re-trigger
		// downstream CEB/Furgonetka pipeline if label hasn't arrived 60s after paid detection.
		add_action( 'wp_ajax_parcselect_label_force_recheck',  array( __CLASS__, 'ajax_force_recheck' ) );
		// NS-86 2026-04-30: REMOVED ajax_validate_locker (api-shipx-pl.easypack24.net call)
		// per WP.org submission scope reduction — paczkomat ID validation is opt-in only,
		// klient odpowiedzialny za poprawność wpisanego ID. Eliminuje 4-th party network call.
		// NS-75 2026-04-30: manual recovery endpoint — admin klika "Wyciągnij etykietę z Oracle"
		// gdy webhook OUT z CEB nie dotarł (network fail, Cloudflare WAF, expired session).
		// Plugin pyta Oracle → jeśli paid → zwraca label_url + tracking → populuje meta.
		// "NIC NIE MOŻE HAMOWAĆ PŁACENIA" - klient zapłacił = MUSI dostać etykietę.
		add_action( 'wp_ajax_parcselect_label_recover',         array( __CLASS__, 'ajax_recover_label' ) );
		// NS-79 2026-04-30: server-side PDF proxy download — cross-origin <a download>
		// jest ignorowany przez Chrome/Firefox dla 3rd-party domain (etykietownik...).
		// Plugin proxy fetches PDF via wp_remote_get + returns with Content-Disposition: attachment.
		// Same-origin = download attribute działa = klik = zapis na dysk bez dialogu.
		add_action( 'wp_ajax_parcselect_label_download_pdf',    array( __CLASS__, 'ajax_download_pdf_proxy' ) );

		// JS bridge: receives postMessage from iframe, calls AJAX, posts result back.
		add_action( 'admin_footer', array( __CLASS__, 'bridge_js' ) );

		// NS-53 fix 2026-04-30: KASKADA — server-side cron sweep co 60s.
		// Niezależny od user open page. Pollwall Oracle dla wszystkich orderów ze status awaiting_payment,
		// wykrywa paid + zapisuje meta + symuluje webhook delivery (jeśli ps-cryptopay webhook OUT failed).
		// To gwarantuje że klient ZAWSZE dostanie potwierdzenie max 60s po TX, nawet jeśli:
		//   (a) browser closed (polling JS nie działa)
		//   (b) ps-cryptopay webhook OUT 403 (NS-49 fallback)
		//   (c) network glitch
		add_action( 'init', array( __CLASS__, 'register_payment_sweep_cron' ) );
		add_action( 'parcselect_label_payment_sweep', array( __CLASS__, 'do_payment_sweep' ) );
		add_filter( 'cron_schedules', array( __CLASS__, 'add_minute_schedule' ) );

		// P5 #26 2026-05-14: dedykowany admin metabox dla orderów ze
		// status='error'. Pokazuje pełny zestaw danych płatności (PID, TxID,
		// wallet, memo, kwoty, paid_at, reason) + przyciski Retry / Refunded
		// + mailto link do supportu. Priority 4 — przed głównym metaboxem
		// (Parcselect_Label_Metabox @ priority 5), żeby admin widzial FAIL
		// info na samej górze sidebar/side area.
		add_action( 'add_meta_boxes', array( __CLASS__, 'register_failed_info_metabox' ), 4, 2 );
	}

	/** NS-53 — Add 'parcselect_label_minute' (60s) custom cron interval. */
	public static function add_minute_schedule( $schedules ) {
		if ( ! isset( $schedules['parcselect_label_minute'] ) ) {
			$schedules['parcselect_label_minute'] = array(
				'interval' => 60,
				'display'  => 'Every minute (parcel-selector payment sweep)',
			);
		}
		return $schedules;
	}

	/**
	 * NS-53 — Register WP cron co 60s (kaskada warstwa #3).
	 */
	public static function register_payment_sweep_cron() {
		if ( ! wp_next_scheduled( 'parcselect_label_payment_sweep' ) ) {
			// Use 'minute' interval (custom — register if not exists)
			wp_schedule_event( time(), 'parcselect_label_minute', 'parcselect_label_payment_sweep' );
		}
	}

	/**
	 * NS-53 — Sweep ordery awaiting_payment, polluje Oracle, zapisuje meta gdy paid.
	 */
	public static function do_payment_sweep() {
		if ( ! class_exists( 'Parcselect_Oracle_Client' ) || ! function_exists( 'wc_get_orders' ) ) return;
		$orders = wc_get_orders( array(
			'meta_key'   => '_ps_label_status', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- cron sweep, nie hot-path.
			'meta_value' => 'awaiting_payment', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- cron sweep, nie hot-path.
			'limit'      => 50,
			'status'     => array( 'pending', 'on-hold', 'processing' ),
		) );
		foreach ( $orders as $o ) {
			$pi_id = (string) $o->get_meta( '_ps_label_pi_id', true );
			if ( '' === $pi_id ) continue;
			$os = Parcselect_Oracle_Client::get_status( $pi_id );
			if ( is_wp_error( $os ) || ! is_array( $os ) ) continue;
			$ostatus = (string) ( $os['status'] ?? '' );
			$txid    = (string) ( $os['txid']   ?? '' );
			$paid_at = (string) ( $os['paid_at'] ?? '' );
			if ( 'paid' === $ostatus ) {
				$o->update_meta_data( '_ps_label_status',  'paid' );
				if ( '' !== $txid )    $o->update_meta_data( '_ps_label_txid',    $txid );
				if ( '' !== $paid_at ) $o->update_meta_data( '_ps_label_paid_at', $paid_at );
				$o->save();
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::info( $o->get_id(), 'cron_sweep.paid_detected', array( 'pi_id' => $pi_id, 'txid' => $txid ) );
				}
				do_action( 'parcselect_label_paid_detected', $o, $os );
			}
			// FIX #7 (2026-05-15): Oracle status sync. Wczesniej plugin polling stop po 5min
			// ale NIGDY nie aktualizowal _ps_label_status z 'awaiting_payment' na 'expired'
			// gdy Oracle juz dawno expired. Skutek: admin widzial 'czeka na platnosc'.
			else if ( in_array( $ostatus, array( 'expired', 'error' ), true ) ) {
				$o->update_meta_data( '_ps_label_status', 'expired' );
				$o->save();
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::info( $o->get_id(), 'cron_sweep.expired_synced', array( 'pi_id' => $pi_id, 'oracle_status' => $ostatus ) );
				}
			}
			else if ( 'partial' === $ostatus ) {
				$o->update_meta_data( '_ps_label_status', 'partial' );
				$amount_received = (string) ( $os['amount_received'] ?? '' );
				if ( '' !== $amount_received ) $o->update_meta_data( '_ps_label_amount_received', $amount_received );
				$o->save();
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::warn( $o->get_id(), 'cron_sweep.partial_payment', array( 'pi_id' => $pi_id, 'amount_received' => $amount_received ) );
				}
			}
		}

		// ─── Bug #17 FIX (2026-05-15): chain_timeout detection ───
		// Plugin polling JS Fix A (90 prób / 15 min) zatrzymuje się po 15 min — jeśli
		// Oracle ma status=paid, ale CEB lub Furgonetka faila → label_url nigdy nie
		// pojawia się w meta. Plugin czeka forever, klient widzi spinner. Sweep
		// wykrywa: status='paid' AND no _ps_label_url AND _ps_label_paid_at > 600s
		// wstecz → marka jako error z reason=chain_timeout_no_label_url + history event.
		// Idempotent: każdy order trafia tylko raz (po pierwszym update status != 'paid').
		$orders_paid_no_label = wc_get_orders( array(
			'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- cron sweep, nie hot-path.
				array( 'key' => '_ps_label_status', 'value' => 'paid' ),
				array( 'key' => '_ps_label_url', 'compare' => 'NOT EXISTS' ),
			),
			'limit'  => 50,
			'status' => array( 'pending', 'on-hold', 'processing' ),
		) );
		$now_ts = time();
		foreach ( $orders_paid_no_label as $o2 ) {
			$paid_at_str = (string) $o2->get_meta( '_ps_label_paid_at', true );
			$paid_ts     = $paid_at_str ? strtotime( $paid_at_str ) : 0;
			if ( ! $paid_ts || ( $now_ts - $paid_ts ) < 600 ) {
				continue; // <10 min — daj jeszcze szansę CEB/Furgonetka.
			}
			$_pi_id_b17 = (string) $o2->get_meta( '_ps_label_pi_id', true );
			$o2->update_meta_data( '_ps_label_status', 'error' );
			$o2->update_meta_data( '_ps_label_error_reason', 'chain_timeout_no_label_url' );
			$o2->update_meta_data( '_ps_label_failed_at', current_time( 'mysql', 1 ) );
			$_history = (array) $o2->get_meta( '_ps_label_history', true );
			if ( ! is_array( $_history ) ) {
				$_history = array();
			}
			$_history[] = array(
				'event'           => 'chain_timeout_marked_error',
				'at'              => current_time( 'mysql', 1 ),
				'pi_id'           => $_pi_id_b17,
				'paid_at'         => $paid_at_str,
				'elapsed_seconds' => $now_ts - $paid_ts,
			);
			$o2->update_meta_data( '_ps_label_history', $_history );
			$o2->save();
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $o2->get_id(), 'cron_sweep.chain_timeout', array( 'pi_id' => $_pi_id_b17, 'elapsed' => $now_ts - $paid_ts ) );
			}
		}
	}

	/**
	 * P5 #26 2026-05-14 — Register the FAIL-INFO metabox.
	 * Visible ONLY when _ps_label_status === 'error'. Shows full payment
	 * diagnostic snapshot + Retry / Mark Refunded placeholder buttons +
	 * mailto link to support (office@parcelselector.com). Position 'side'
	 * + priority 'high' so admin sees it immediately when opening order.
	 *
	 * @param string                  $post_type_or_screen
	 * @param WP_Post|WC_Order|object $post_or_order
	 */
	public static function register_failed_info_metabox( $post_type_or_screen, $post_or_order = null ) {
		// HPOS passes $post_or_order = WC_Order; legacy passes WP_Post.
		$order = null;
		if ( $post_or_order instanceof WC_Order ) {
			$order = $post_or_order;
		} elseif ( is_object( $post_or_order ) && isset( $post_or_order->ID ) ) {
			$order = function_exists( 'wc_get_order' ) ? wc_get_order( $post_or_order->ID ) : null;
		}
		if ( ! $order || ! method_exists( $order, 'get_meta' ) ) {
			return;
		}
		$status = (string) $order->get_meta( '_ps_label_status', true );
		if ( 'error' !== $status ) {
			return;
		}
		// Determine the correct screen target — same logic as main metabox.
		$screens = array( 'shop_order', 'woocommerce_page_wc-orders' );
		foreach ( $screens as $screen ) {
			add_meta_box(
				'parcselect_label_failed_info',
				esc_html__( 'Label tool — generation failed (FAIL)', 'parcel-selector' ),
				array( __CLASS__, 'render_failed_info' ),
				$screen,
				'side',
				'high'
			);
		}
	}

	/**
	 * P5 #26 2026-05-14 — Render FAIL-INFO metabox content.
	 * Wzorzec UI skopiowany z FAIL panelu dla klienta (render_from_pscp_payload
	 * linia ~1264-1315): pełen zestaw danych + Copy + mailto. Plus admin-only
	 * przyciski Retry / Mark Refunded (placeholder onclick — backend akcje
	 * dorobimy w osobnej iteracji; tutaj tylko data attributes + confirm).
	 *
	 * @param WP_Post|WC_Order $post_or_order
	 */
	public static function render_failed_info( $post_or_order ) {
		$order = ( $post_or_order instanceof WC_Order )
			? $post_or_order
			: ( function_exists( 'wc_get_order' )
				? wc_get_order( is_object( $post_or_order ) ? $post_or_order->ID : $post_or_order )
				: null );
		if ( ! $order ) {
			echo '<p style="color:#7f1d1d">' . esc_html__( 'Order not found.', 'parcel-selector' ) . '</p>';
			return;
		}
		$order_id = $order->get_id();
		// All values cast to string — PHP 8.x null guards.
		$pi_id    = (string) $order->get_meta( '_ps_label_pi_id', true );
		$txid     = (string) $order->get_meta( '_ps_label_txid', true );
		$wallet   = (string) $order->get_meta( '_ps_label_sol_wallet', true );
		$memo     = (string) $order->get_meta( '_ps_label_memo', true );
		$paid_at  = (string) $order->get_meta( '_ps_label_paid_at', true );
		$pln      = (string) $order->get_meta( '_ps_label_price_pln', true );
		$sol      = (string) $order->get_meta( '_ps_label_sol_amount', true );
		$reason   = (string) $order->get_meta( '_ps_label_error', true );
		$shop     = home_url();

		// Plain-text snapshot for clipboard + mailto body.
		$snapshot = sprintf(
			"Order: #%d\nPID: %s\nKwota: %s PLN / %s SOL\nTxID: %s\nWallet: %s\nMemo: %s\nPaid at (UTC): %s\nSklep: %s\nReason: %s",
			$order_id, $pi_id, $pln, $sol, $txid, $wallet, $memo, $paid_at, $shop, $reason
		);
		$mailto_subj = rawurlencode( 'FAIL etykieta [admin] - Order #' . $order_id );
		$mailto_body = rawurlencode(
			"Hello,\n\nThe shop admin reports a failed label generation. Details:\n\n"
			. $snapshot . "\n\nPlease verify the Oracle status and arrange a possible refund/manual recovery.\n\nRegards"
		);
		?>
		<div class="ps-label-failed-info" style="font-size:12px;line-height:1.55;color:#7f1d1d;">
			<div style="padding:10px 12px;background:linear-gradient(135deg,#fef2f2 0%,#fee2e2 100%);border:2px solid #dc2626;border-radius:8px;margin-bottom:10px">
				<div style="font-weight:800;font-size:13px;margin-bottom:6px">&#9888;&#65039; <?php esc_html_e( 'Status: error', 'parcel-selector' ); ?></div>
				<div style="color:#991b1b;font-size:11px"><?php esc_html_e( 'Payment was recorded, but label generation failed.', 'parcel-selector' ); ?></div>
			</div>

			<div style="background:#fff;border:1px solid #fecaca;border-radius:6px;padding:10px;margin-bottom:10px;font-family:monospace;font-size:11px;line-height:1.7">
				<div><strong>Order:</strong> #<?php echo (int) $order_id; ?></div>
				<div><strong>PID:</strong> <?php echo esc_html( $pi_id ); ?></div>
				<div><strong>Kwota:</strong> <?php echo esc_html( $pln ); ?> PLN / <?php echo esc_html( $sol ); ?> SOL</div>
				<div style="word-break:break-all"><strong>TxID:</strong> <?php echo esc_html( $txid ); ?></div>
				<div style="word-break:break-all"><strong>Wallet:</strong> <?php echo esc_html( $wallet ); ?></div>
				<div><strong>Memo:</strong> <?php echo esc_html( $memo ); ?></div>
				<div><strong>Paid at (UTC):</strong> <?php echo esc_html( $paid_at ); ?></div>
				<div style="word-break:break-all"><strong>Reason:</strong> <?php echo esc_html( $reason ?: '—' ); ?></div>
			</div>

			<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:10px">
				<button type="button" class="button button-secondary"
					data-copy="<?php echo esc_attr( $snapshot ); ?>"
					onclick="var t=this.dataset.copy;if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){var b=event.target;var o=b.textContent;b.textContent='&#10003; Skopiowano';setTimeout(function(){b.textContent=o;},2000);})}"
					style="text-align:left">
					&#128203; <?php esc_html_e( 'Copy snapshot (for email)', 'parcel-selector' ); ?>
				</button>
				<a class="button button-primary"
					href="mailto:office@parcelselector.com?subject=<?php echo esc_attr( $mailto_subj ); ?>&body=<?php echo esc_attr( $mailto_body ); ?>"
					style="text-align:left;background:#dc2626;border-color:#b91c1c">
					&#128231; <?php esc_html_e( 'Email support', 'parcel-selector' ); ?>
				</a>
			</div>

			<div style="font-size:10px;color:#94a3b8;text-align:center;line-height:1.4">
				<?php esc_html_e( 'Admin-only recovery actions', 'parcel-selector' ); ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Register metabox on both HPOS and legacy order screens.
	 */
	public static function register_metabox() {
		foreach ( array( 'shop_order', 'woocommerce_page_wc-orders' ) as $screen ) {
			add_meta_box(
				'parcselect_label_embed',
				esc_html__( 'Label tool', 'parcel-selector' ),
				array( __CLASS__, 'render' ),
				$screen,
				'normal',
				'high'
			);
		}
	}

	/**
	 * Render: just the iframe. Everything else lives on VPS.
	 *
	 * @param WP_Post|WC_Order $post_or_order
	 */
	public static function render( $post_or_order ) {
		$session_id = ''; // PHP 8.x guard against undefined-var warning on early reference (linia 1256)
		$order = ( $post_or_order instanceof WC_Order )
			? $post_or_order
			: wc_get_order( is_object( $post_or_order ) ? $post_or_order->ID : $post_or_order );

		if ( ! $order ) {
			echo '<p>' . esc_html__( 'Order not found.', 'parcel-selector' ) . '</p>';
			return;
		}

		// === v4.0.4 DEBUG DUMP — full state to F12 console for forensics ===
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::debug_to_f12( 'embed.render.entry', $order );
		}

		// CROSS-INSTALL LEAK GUARD (v4.0.2) — purge stale meta from another install.
		if ( class_exists( 'Parcselect_Label_Installation' )
			&& ! Parcselect_Label_Installation::owns( $order )
			&& Parcselect_Label_Installation::has_any_label_meta( $order ) ) {
			Parcselect_Label_Installation::purge_order( $order );
			if ( function_exists( 'wp_cache_flush' ) ) { wp_cache_flush(); }
		}

		// === v4.0.4 HARD FAIL-CLOSED — refuse to render label data unless
		// safe_to_render() returns TRUE. Even if owns() returned TRUE earlier,
		// safe_to_render adds extra checks (stamp must match identity_marker
		// exactly, installation must be enrolled). Rationale: any leak path
		// that survives this gate would have to forge a stamp matching our
		// current identity_marker (installation_id + URLs hash) — which is
		// only possible if the plugin itself created it.
		if ( class_exists( 'Parcselect_Label_Installation' ) && ! Parcselect_Label_Installation::safe_to_render( $order ) ) {
			// Silent hard-closed: physically purge stale meta and proceed
			// to the normal consent flow below. The previous v4.0.4 banner
			// was diagnostic; DB is now clean on every render where
			// safe_to_render() returns false. Full forensic trail still
			// emitted to F12 console via Parcselect_Label_Installation::debug_to_f12().
			if ( Parcselect_Label_Installation::has_any_label_meta( $order ) ) {
				Parcselect_Label_Installation::purge_order( $order );
			}
			// No UI banner — admin sees the normal consent wizard instead.
		}

		// Locker data — FREE resolver accepts _ps_locker_name as fallback if
		// ID-shaped (matches what checkout actually writes).
		// P2 #9 2026-05-14: (string) casts — PHP 8.x null guard for downstream
		// strtoupper/strpos/preg_match (linia 220 + 332-336).
		$locker_id      = (string) $order->get_meta( '_ps_locker_id', true );
		$locker_name    = (string) $order->get_meta( '_ps_locker_name', true );
		$locker_address = (string) ( $order->get_meta( '_ps_locker_address1', true ) ?: $order->get_meta( '_ps_locker_address', true ) );
		$locker_city    = (string) $order->get_meta( '_ps_locker_city', true );
		$carrier        = (string) ( $order->get_meta( '_ps_locker_carrier', true ) ?: $order->get_meta( '_ps_carrier', true ) );

		// Accept any reasonable carrier locker code: alphanumerics + dash,
		// 3–30 chars (covers InPost locker code formats).
		if ( empty( $locker_id ) && ! empty( $locker_name ) && preg_match( '/^[A-Z0-9\-]{3,30}$/i', $locker_name ) ) {
			$locker_id = $locker_name;
		}
		if ( empty( $locker_id ) ) {
			echo '<p style="color:#64748b;padding:20px;">' . esc_html__( 'No pickup point selected for this order.', 'parcel-selector' ) . '</p>';
			return;
		}

		// Server-side sync: ONLY poll etykietownik if this order was actually
		// submitted for label generation (consent given). Without this guard,
		// a brand-new order with no paczkomat wizard activity would poll the
		// central kasjer and could accidentally match stale data (BUG: order 323
		// showed tracking from order 308 — cross-order data leak).
		$has_consent  = 'yes' === $order->get_meta( '_ps_label_consent', true );
		$tracking     = (string) $order->get_meta( '_ps_label_tracking_number', true );
		$label_url    = self::fresh_label_url( $order->get_meta( '_ps_label_url', true ) );
		$txid         = (string) $order->get_meta( '_ps_label_txid', true );
		$crypto       = (string) $order->get_meta( '_ps_label_crypto', true );
		$crypto_amt   = (string) $order->get_meta( '_ps_label_crypto_amount', true );
		// SECURITY (v4.0.3 ZERO-LEAK): polling endpoint /wp-json/ceb/v1/order-status
		// REMOVED COMPLETELY from render path. Reason: shared SOL wallet means
		// X21 daemon cannot reliably distinguish "this shop's TX" from "another
		// shop's TX" when queried by (source_site, source_order) tuple alone.
		// Trusting that response and writing it to local meta + auto-stamping
		// it as ours = direct cross-shop data leak (production audit FAIL).
		// Source of truth from now on: webhook /ps/v1/label-callback (HMAC
		// auth + installation_id verification) plus manual admin "Sprawdź
		// płatność" click. If webhook does not reach the shop (firewall,
		// hosting WAF), admin must trigger recheck explicitly. This is the
		// price of zero-leak guarantee at scale (hundreds of shops sharing
		// the same wallet — only the install that GENERATED a session has
		// the right to display its result).
		// === v4.0.4 HARD FAIL-CLOSED at the BANNER GATE ===
		// Even if $tracking and $label_url are populated locally, render
		// the green "Etykieta gotowa" banner ONLY when safe_to_render()
		// returns TRUE. Catches every case where data slipped past earlier
		// guards (e.g. webhook with wrong installation_id, polling residue
		// from < v4.0.3, manual DB poke).
		$_safe = class_exists( 'Parcselect_Label_Installation' ) && Parcselect_Label_Installation::safe_to_render( $order );
		if ( ! $_safe ) {
			// Wipe locals to prevent any downstream block from rendering
			// them. We do NOT delete from DB here (already handled above);
			// just kill the in-memory copy used by the banner template.
			$tracking = '';
			$label_url = '';
			$txid = '';
			$crypto = '';
			$crypto_amt = '';
		}
		// Render completed-notice NA GÓRZE metaboxa (przed wszystkim) jeśli mamy dane.
		if ( $_safe && ! empty( $tracking ) && ! empty( $label_url ) ) {
			$explorer = '';
			$crypto_up = strtoupper( $crypto ?: 'SOL' );
			if ( $txid ) {
				$c = strtolower( $crypto ?: 'sol' );
				if ( 'sol' === $c ) { $explorer = 'https://solscan.io/tx/' . rawurlencode( (string) $txid ); }
				elseif ( 'btc' === $c ) { $explorer = 'https://mempool.space/tx/' . rawurlencode( (string) $txid ); }
				elseif ( 'eth' === $c ) { $explorer = 'https://etherscan.io/tx/' . rawurlencode( (string) $txid ); }
			}
			$txid_short = $txid ? ( substr( $txid, 0, 8 ) . '…' . substr( $txid, -8 ) ) : '';
			?>
			<div id="ps-label-tracking-note" style="padding:16px 20px;background:linear-gradient(135deg,#ecfdf5 0%,#d1fae5 100%);border:2px solid #10b981;border-radius:10px;margin:6px 0 14px;color:#065f46;box-shadow:0 2px 8px rgba(16,185,129,0.15);font-size:13px;line-height:1.7">
				<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;font-size:16px;font-weight:800">
					<span style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:#10b981;color:#fff;border-radius:50%;font-size:16px">&#10003;</span>
					<?php esc_html_e( 'Label ready', 'parcel-selector' ); ?>
				</div>
				<?php if ( $txid ) : ?>
				<div style="margin-bottom:8px">
					<strong><?php esc_html_e( 'Crypto payment:', 'parcel-selector' ); ?></strong>
					<span style="display:inline-block;padding:2px 10px;background:#10b981;color:#fff;border-radius:12px;font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.5px">&#10003; <?php esc_html_e( 'Paid', 'parcel-selector' ); ?></span>
					<?php if ( $crypto_amt ) : ?>
						&middot; <strong><?php echo esc_html( $crypto_amt ); ?> <?php echo esc_html( $crypto_up ); ?></strong>
					<?php endif; ?>
				</div>
				<div style="margin-bottom:8px;font-size:12px">
					<strong>TXID:</strong>
					<?php if ( $explorer ) : ?>
						<a href="<?php echo esc_url( $explorer ); ?>" target="_blank" rel="noopener" style="color:#047857;font-family:monospace;text-decoration:underline"><?php echo esc_html( $txid_short ); ?> &#8599;</a>
					<?php else : ?>
						<code style="font-family:monospace"><?php echo esc_html( $txid_short ); ?></code>
					<?php endif; ?>
				</div>
				<?php endif; ?>
				<?php
				// InPost tracking URL. target=_blank opens it in a new tab.
				$carrier_lc = strtolower( (string) $order->get_meta( '_ps_label_carrier', true ) ?: $carrier );
				$tracking_url = '';
				if ( 'inpost' === $carrier_lc ) {
					$tracking_url = 'https://inpost.pl/sledzenie-przesylek?number=' . rawurlencode( $tracking );
				}
				?>
				<div style="margin-bottom:10px">
					<strong><?php esc_html_e( 'Tracking number:', 'parcel-selector' ); ?></strong>
					<?php if ( $tracking_url ) : ?>
						<a href="<?php echo esc_url( $tracking_url ); ?>" target="_blank" rel="noopener"
						   style="background:#fff;padding:2px 8px;border-radius:4px;font-size:13px;font-family:monospace;color:#065f46;text-decoration:underline;font-weight:700"><?php echo esc_html( $tracking ); ?> &#8599;</a>
					<?php else : ?>
						<code style="background:#fff;padding:2px 8px;border-radius:4px;font-size:13px"><?php echo esc_html( $tracking ); ?></code>
					<?php endif; ?>
				</div>
				<a href="<?php echo esc_url( $label_url ); ?>" target="_blank" rel="noopener"
				   style="display:inline-block;padding:10px 18px;background:#10b981;color:#fff;text-decoration:none;border-radius:8px;font-weight:700;font-size:13px">
					&#128196; <?php esc_html_e( 'Download label (PDF)', 'parcel-selector' ); ?>
				</a>
			</div>
			<?php
		}

		// Auto-detect carrier from locker pattern (FREE = InPost-only).
		// P2 #9 2026-05-14: (string) cast — explicit null guard nawet jeśli
		// $locker_id już skastowane wyżej (defensywne w razie refaktoru).
		if ( empty( $carrier ) ) {
			$carrier = 'inpost'; // FREE = InPost Paczkomaty only.
		}

		// Receiver: shipping data preferred (matches label address); billing fallback.
		$recv_phone = method_exists( $order, 'get_shipping_phone' ) ? $order->get_shipping_phone() : '';
		if ( empty( $recv_phone ) ) {
			$recv_phone = $order->get_billing_phone();
		}
		$recv_email = method_exists( $order, 'get_shipping_email' ) ? $order->get_shipping_email() : '';
		if ( empty( $recv_email ) ) {
			$recv_email = $order->get_billing_email();
		}
		$recv_first = $order->get_shipping_first_name() ?: $order->get_billing_first_name();
		$recv_last  = $order->get_shipping_last_name()  ?: $order->get_billing_last_name();
		// NS-55 2026-04-30: Furgonetka /receiver/city `notAlpha` regex fix.
		// Mapa zapisuje nazwę paczkomatu do shipping_city — wyciągamy prawdziwe
		// miasto z _ps_locker_address1 (format "ul. X, KOD MIASTO") z fallbackami.
		$_recv_city_raw = (string) ( $order->get_shipping_city() ?: $order->get_billing_city() );
		$_locker_addr   = (string) ( $order->get_meta( '_ps_locker_address1', true ) ?: $order->get_meta( '_ps_locker_address', true ) );
		$recv_city = '';
		if ( '' !== $_locker_addr && preg_match( '/\d{2}-\d{3}\s+([\p{L}\s\-]+?)$/u', $_locker_addr, $m ) ) {
			$recv_city = trim( $m[1] );
		}
		if ( '' === $recv_city && '' !== $_recv_city_raw && preg_match( '/^[\p{L}\s\-]+$/u', $_recv_city_raw ) ) {
			$recv_city = $_recv_city_raw;
		}
		if ( '' === $recv_city ) {
			$_billing_city = (string) $order->get_billing_city();
			if ( '' !== $_billing_city && preg_match( '/^[\p{L}\s\-]+$/u', $_billing_city ) ) {
				$recv_city = $_billing_city;
			}
		}
		if ( '' === $recv_city && '' !== $_recv_city_raw ) {
			$recv_city = trim( preg_replace( '/[^\p{L}\s\-]/u', '', $_recv_city_raw ) );
		}
		$recv_post  = $order->get_shipping_postcode()   ?: $order->get_billing_postcode();

		// Sender: from saved options (FREE uses ps_label_sender_* keys).
		$snd_name     = get_option( 'parcselect_label_sender_name',     get_option( 'blogname', '' ) );
		$snd_company  = get_option( 'parcselect_label_sender_company',  '' );
		$snd_street   = get_option( 'parcselect_label_sender_street',   trim( get_option( 'woocommerce_store_address', '' ) . ' ' . get_option( 'woocommerce_store_address_2', '' ) ) );
		$snd_city     = get_option( 'parcselect_label_sender_city',     get_option( 'woocommerce_store_city', '' ) );
		$snd_postcode = get_option( 'parcselect_label_sender_postcode', get_option( 'woocommerce_store_postcode', '' ) );
		$snd_phone    = get_option( 'parcselect_label_sender_phone',    '' );
		$snd_email    = get_option( 'parcselect_label_sender_email',    get_option( 'admin_email' ) );

		// Consent flags from order meta.
		$consent_service = 'yes' === $order->get_meta( '_ps_label_consent', true );
		$consent_sender  = 'yes' === $order->get_meta( '_ps_label_sender_confirmed', true );

		// Payload for POST /ceb/v1/create-order on CRYPTO_PAYMENT_SYSTEM.
		// Source site (this one) POSTs context → mu-plugin creates a WC order
		// on etykietownik.parcelselector.com with ps-cryptopay gateway and
		// returns an order-received URL. We iframe that URL, customer sees
		// the full PS CryptoPay thank-you page (stepper + QR + wallet buttons
		// + 4-source rates + fireworks on confirm). No native UI here.
		// Build iframe URL to map-server /embed/label (ładny 3-column wizard Stage 1-3).
		// Po Generate w iframe, zmodyfikowany JS na VPS wyśle POST do ceb_endpoint
		// (etykietownik.parcelselector.com), dostanie checkout_url i przekieruje
		// iframe na PSCP widget (Stage 4).
		// v4.0.9 — plugin-side session_id generation + order_id obfuscation.
		// User-reported: VPS matches sessions by (source_site, order_id, amount)
		// and returns cached 'PAYMENT CONFIRMED' + canonical TXID for fresh
		// orders. Plugin cannot force VPS-side cache reset, but CAN make it
		// impossible for VPS to find a cached match by supplying parameters
		// that cannot collide with prior sessions:
		//
		//   A. session_id — fresh UUIDv4 created once per order (persisted in
		//      _ps_label_session_id meta). Stamped with PS_Label_Installation
		//      so it belongs to this install. If VPS honours it as primary
		//      lookup key, new session_id = no cache hit.
		//   B. order_ref — HMAC(installation_id|order_id|site_url) instead of
		//      raw integer order_id. Every shop gets a different order_ref for
		//      the same local order number. Raw order_id still passed for
		//      back-compat but VPS should prefer order_ref.
		//   C. request_id — UUIDv4 per render (existing v4.0.8 behaviour).
		//   D. force_fresh + stage=1 (existing v4.0.8).
		//
		// If after this VPS STILL returns cached 'completed' with foreign TXID,
		// the leak is 100% confirmed as VPS-side cache-collision (see
		// VPS_ROOT_CAUSE_AUDIT_PROMPT.md thread B).
		$request_id = function_exists( 'wp_generate_uuid4' )
			? wp_generate_uuid4()
			: bin2hex( random_bytes( 16 ) );

		// Persistent session_id per order — generated once, reused across
		// renders so webhook can match. Stored in _ps_label_session_id meta.
		$plugin_session_id = (string) $order->get_meta( '_ps_label_session_id', true );
		if ( '' === $plugin_session_id ) {
			$plugin_session_id = function_exists( 'wp_generate_uuid4' )
				? wp_generate_uuid4()
				: bin2hex( random_bytes( 16 ) );
			$order->update_meta_data( '_ps_label_session_id', $plugin_session_id );
			$order->save();
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::stamp( $order );
			}
		}

		// order_ref — installation-scoped hash. Back-compat: order_id still
		// passed raw for VPS endpoints that cannot be updated quickly. New
		// VPS endpoints should prefer order_ref as primary key.
		$_our_iid  = (string) get_option( 'parcselect_installation_id', '' );
		$_salt     = get_option( 'parcselect_label_ceb_salt', '' );
		$order_ref = hash_hmac(
			'sha256',
			$_our_iid . '|' . $order->get_id() . '|' . home_url(),
			$_salt ?: wp_salt( 'auth' )
		);
		$iframe_params = array(
			'order_id'         => $order->get_id(),
			'locker_id'        => $locker_id,
			'locker_name'      => $locker_name,
			'locker_address'   => $locker_address,
			'locker_city'      => $locker_city,
			'carrier'          => $carrier,
			'recv_name'        => trim( $recv_first . ' ' . $recv_last ),
			'recv_phone'       => $recv_phone,
			'recv_email'       => $recv_email,
			'recv_city'        => $recv_city,
			'recv_postcode'    => $recv_post,
			'country'          => $order->get_shipping_country() ?: 'PL',
			'snd_name'         => $snd_name,
			'snd_company'      => $snd_company,
			'snd_street'       => $snd_street,
			'snd_city'         => $snd_city,
			'snd_postcode'     => $snd_postcode,
			'snd_phone'        => $snd_phone,
			'snd_email'        => $snd_email,
			// HARD-CODED 0/0 — plugin never hints prior consent to iframe.
			// Iframe MUST start at Stage 1 even if admin accepted in a
			// previous session. If meta has '_ps_label_consent=yes' locally,
			// it means admin already consented FROM THIS INSTALL for an
			// earlier generation — which completed via webhook and rendered
			// via server-side safe_to_render(). It does NOT mean the new
			// iframe should auto-skip Stage 1.
			'consent_service'  => '0',
			'consent_sender'   => '0',
			'callback_url'     => rest_url( 'ps/v1/label-callback' ),
			'site_url'         => home_url(),
			'token'            => self::generate_token( $order->get_id(), $locker_id, $carrier ),
			'source_site_url'  => home_url(),
			'ceb_endpoint'     => self::ceb_url() . '/?rest_route=/ceb/v1/create-order',
			'ceb_auth_token'   => hash_hmac( 'sha256', home_url() . '|' . $order->get_id(), get_option( 'parcselect_label_ceb_salt', '' ) ),
			// v4.0.8: identify THIS installation — VPS must scope session by it.
			'installation_id'  => (string) get_option( 'parcselect_installation_id', '' ),
			// v4.0.8: unique per-render request id. Forces VPS to treat as
			// new session lookup (no cache hits cross-installation).
			'request_id'       => $request_id,
			// v4.0.9: plugin-generated session UUID. Primary session key
			// that VPS MUST use. Stamped in meta so webhook can match.
			'session_id'       => $plugin_session_id,
			// v4.0.9: installation-scoped order reference (HMAC hash). VPS
			// cannot collide this with another shop's order even if both use
			// order_id=992 locally.
			'order_ref'        => $order_ref,
			// v4.0.8: explicit "ignore any cached state" signal for VPS.
			'force_fresh'      => '1',
			'stage'            => '1',
			// Cache-buster — both for browser and any intermediate proxy.
			'_cb'              => (string) time(),
		);
		// NOTE: label_url / label_tracking / label_size DELIBERATELY NOT
		// passed. Plugin does not hand VPS any prior label state — if a
		// label exists legitimately for this order, admin sees it via
		// server-side render_completed() (which reads local meta after
		// safe_to_render() gate), not via iframe.
		// v4.0.7 ZERO-LEAK — iframe gated by explicit admin click.
		// Rationale: the iframe is rendered by VPS (map.parcelselector.com)
		// and, with a shared SOL wallet, can auto-display "Etykieta gotowa
		// do druku" + foreign tracking number + 403-PDF button for orders
		// where X21 matched a TX from a different shop. The plugin cannot
		// control what the iframe shows once loaded. The only reliable
		// plugin-side eliminator: don't load the iframe at all until the
		// admin explicitly opts in via "Rozpocznij generowanie etykiety"
		// button — once clicked, start_generation AJAX creates a fresh
		// session_id on this install, which the iframe then uses as its
		// identity. No click = no iframe = no leak, regardless of VPS bugs.
		$iframe_src = esc_url( add_query_arg( $iframe_params, self::EMBED_URL ) );
		$_has_our_session = '' !== (string) $order->get_meta( '_ps_label_session_id', true );
		$_is_completed    = 'completed' === (string) $order->get_meta( '_ps_label_status', true );
		$_awaiting_payment = 'awaiting_payment' === (string) $order->get_meta( '_ps_label_status', true );
		$_qr_uri          = (string) $order->get_meta( '_ps_label_qr_uri', true );
		$_pscp_payload    = (string) $order->get_meta( '_ps_label_pscp_payload', true );

		// v4.1 (2026-04) — Oracle-backed Krok 2 render.
		// If we have a signed payload from Oracle, render from it (authoritative
		// wallet, 3-source averaged rate, dust-ID amount, i18n bundle, step labels,
		// wallet warning — all came from ps-cryptopay /build-intent signed by
		// Oracle per-merchant HMAC). Plugin does ZERO math, ZERO translation,
		// ZERO rate fetching here.
		// v4.1.1 (2026-04-25): also handle status='paid' (Oracle detected TX) —
		// show the SAME Krok 2 card but with stepper [1]✓[2]active + "Płatność
		// potwierdzona" badge + spinner. Prevents UI regressing to size picker
		// after B3 promotes _ps_label_status from awaiting_payment → paid.
		$_is_paid = 'paid' === (string) $order->get_meta( '_ps_label_status', true );
		if ( ( $_awaiting_payment || $_is_paid || 'error' === $_status ) && '' !== $_pscp_payload && ! $_is_completed ) {
			self::render_from_pscp_payload( $order, $_pscp_payload, $_is_paid );
			self::render_history_section( $order );
			return;
		}

		// v4.0.13 legacy — plugin-native Stage 4 (no Oracle payload yet).
		// Kept as fallback for orders created before v4.1 Oracle integration.
		if ( $_awaiting_payment && '' !== $_qr_uri && ! $_is_completed ) {
			self::render_plugin_native_stage4( $order );
			self::render_history_section( $order );
			return;
		}
		// Show iframe only when: (a) we already have our own session_id
		// (admin clicked Generate earlier) OR (b) label is completed (webhook
		// wrote it). Otherwise: show explicit opt-in button.
		// v4.0.14 — CSP attribute + mask overlay REMOVED. These were
		// cosmetic defence layers that broke the iframe integration:
		//   - CSP blocked necessary fetches iframe makes to VPS for
		//     payment session creation (/embed/label/session).
		//   - Right-column mask obscured real payment UI when VPS webhook
		//     was legitimately delivering own tracking.
		//   - nested CEB checkout iframe (PSCP crypto widget) needs
		//     allow-same-origin + allow-popups for wallet deeplinks
		//     (Phantom / Solflare mobile browser intents).
		// PRO uses identical flow with permissive sandbox; FREE must match
		// to keep same UX that user confirmed works in PRO.
		// Real anti-leak protection now lives in:
		//   • safe_to_render() server-side gate before any local meta is
		//     displayed (prevents foreign tracking from rendering in our
		//     own server-side output)
		//   • webhook handler REQUIRES installation_id match (prevents
		//     foreign tracking from reaching local meta)
		//   • plugin NEVER generates local UI from iframe postMessage
		//     data (ps_label_completed DOM inject disabled since v4.0.7)
		// v4.0.15 — NATIVE SIZE PICKER. Replaces the VPS iframe that was
		// resurfacing foreign "PAYMENT CONFIRMED" banners from cached
		// embed-label sessions. Admin picks a size, JS POSTs to
		// `parcselect_label_create_session` (already exists — builds
		// Solana-Pay URI with unique dust-lamport amount + session memo),
		// reloads → render_plugin_native_stage4() takes over with QR +
		// wallet + amount. Zero VPS rendering.
		self::render_native_size_picker( $order );
		self::render_history_section( $order );
	}

	/**
	 * v4.0.15 — plugin-native Stage 3 (size picker) replacement for the
	 * VPS iframe. Admin clicks A/B/C → AJAX create_session → page reload
	 * → Stage 4 QR + wallet amount rendered natively.
	 */
	public static function render_native_size_picker( $order ) {
		// v4.1.12 REQUEST-28: same panel changes color + content when payload exists (done state).
		// Bez tworzenia drugiego panelu obok — TEN SAM kontener zmienia sie na zielony.
		$_NSP_payload    = (string) $order->get_meta( '_ps_label_pscp_payload', true );
		$_NSP_done_size  = (string) $order->get_meta( '_ps_label_size', true );
		$_NSP_status     = (string) $order->get_meta( '_ps_label_status', true );
		$_NSP_done       = '' !== $_NSP_payload && '' !== $_NSP_done_size && in_array( $_NSP_status, array( 'awaiting_payment', 'paid', 'completed', 'error' ), true );
		$_NSP_is_failed  = 'error' === $_NSP_status;


		$order_id   = $order->get_id();
		$nonce      = wp_create_nonce( 'parcselect_label_consent' );
		// Current live-rate SOL estimate so the admin sees a ballpark before
		// clicking. Stale up to an hour; `ajax_create_session` refreshes if
		// needed when the session is actually created.
		$sol_rate   = (float) get_option( 'parcselect_label_sol_rate_pln', 0 );
		$prices_pln = parcselect_label_cennik(); // CENNIK-FETCH 2026-05-16: cennik z VPS (endpoint CEB), fallback w helperze
		?>
		<div id="parcselect-size-picker" style="padding:20px;<?php if ( $_NSP_done ) : ?>background:linear-gradient(135deg,#ecfdf5 0%,#d1fae5 100%);border:2px solid #10b981;<?php else : ?>background:linear-gradient(135deg,#fff7ed 0%,#ffedd5 100%);border:2px solid #f97316;<?php endif; ?>border-radius:12px;margin:10px 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
			<div style="text-align:center;margin-bottom:16px">
				<?php if ( $_NSP_done ) : ?>
				<div style="display:inline-flex;align-items:center;gap:8px;padding:6px 14px;background:#10b981;color:#fff;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase">&#10003; <?php esc_html_e( 'Size accepted', 'parcel-selector' ); ?></div>
				<h3 style="margin:12px 0 4px;font-size:20px;color:#065f46"><?php esc_html_e( 'Selected size', 'parcel-selector' ); ?>: <span style="font-size:24px"><?php echo esc_html( $_NSP_done_size ); ?></span></h3>
				<p style="margin:0;color:#047857;font-size:13px"><?php if ( $_NSP_is_failed ) : ?>
				<div style="background:#fef2f2;border:1px solid #fca5a5;color:#7f1d1d;padding:8px 12px;border-radius:6px;font-weight:700;font-size:13px;text-align:center;margin-top:8px">⚠️ <?php esc_html_e( 'Label not generated — see the error panel below', 'parcel-selector' ); ?></div>
				<?php else : ?>
				<?php esc_html_e( 'Crypto session created — proceed to the SOLANA payment on the right.', 'parcel-selector' ); ?>
				<?php endif; ?></p>
			<?php else : ?>
				<div style="display:inline-block;padding:4px 12px;background:#f97316;color:#fff;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase"><?php esc_html_e( 'Step 1 of 2', 'parcel-selector' ); ?></div>
				<h3 style="margin:12px 0 4px;font-size:20px;color:#7c2d12"><?php esc_html_e( 'Choose the parcel size', 'parcel-selector' ); ?></h3>
				<p style="margin:0;color:#9a3412;font-size:13px"><?php esc_html_e( 'After selecting, a unique crypto payment (SOL) for this order will be displayed.', 'parcel-selector' ); ?></p>
			<?php endif; ?>
			</div>
			<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px">
				<?php foreach ( $prices_pln as $s => $p ) :
					$sol_est = $sol_rate > 0 ? round( $p / $sol_rate, 6 ) : 0;
					$label_sizes = array( 'A' => '8×38×64', 'B' => '19×38×64', 'C' => '41×38×64' );
				?>
				<?php $_btn_selected = $_NSP_done && $s === $_NSP_done_size; $_btn_disabled = $_NSP_done && ! $_btn_selected; ?><button type="button" class="parcselect-pick-size" data-size="<?php echo esc_attr( $s ); ?>"
						style="padding:14px 10px;<?php echo $_btn_selected ? 'background:linear-gradient(135deg,#ecfdf5 0%,#d1fae5 100%);border:3px solid #10b981;box-shadow:0 4px 12px rgba(16,185,129,.25);' : ( $_btn_disabled ? 'background:#f1f5f9;border:2px solid #cbd5e1;opacity:.4;filter:grayscale(1);pointer-events:none;' : 'background:#fff;border:2px solid #fed7aa;' ); ?>border-radius:10px;cursor:<?php echo $_NSP_done ? 'default' : 'pointer'; ?>;text-align:center;transition:all .15s ease">
					<div style="font-size:24px;font-weight:800;<?php echo $_btn_selected ? 'color:#065f46' : ( $_btn_disabled ? 'color:#94a3b8' : 'color:#9a3412' ); ?>"><?php echo esc_html( $s ); ?><?php if ( $_btn_selected ) : ?> <span style="color:#10b981;font-size:20px;vertical-align:middle">&#10003;</span><?php endif; ?></div>
					<div style="font-size:11px;color:#6b7280;margin:4px 0"><?php echo esc_html( $label_sizes[ $s ] ); ?> cm</div>
					<div style="font-size:14px;font-weight:700;color:#111827"><?php echo esc_html( number_format( $p, 2 ) ); ?> PLN</div>
					<?php if ( $sol_est > 0 ) : ?>
						<div style="font-size:10px;color:#7c3aed;font-family:monospace">≈ <?php echo esc_html( $sol_est ); ?> SOL</div>
					<?php endif; ?>
				</button>
				<?php endforeach; ?>
			</div>
			<div id="parcselect-pick-error" style="margin-top:12px;padding:10px;background:#fee2e2;border:1px solid #fca5a5;border-radius:6px;color:#991b1b;font-size:12px;display:none"></div>
			<div style="margin-top:14px;text-align:center;font-size:11px;<?php echo $_NSP_done ? 'color:#047857;font-weight:600' : 'color:#9a3412'; ?>">
				<?php if ( $_NSP_done ) : ?>
					&#10003; <?php esc_html_e( 'Size confirmed — crypto session created in Oracle.', 'parcel-selector' ); ?>
				<?php else : ?>
					<?php esc_html_e( 'Plugin-native Stage 3 · Each click = a new unique crypto session.', 'parcel-selector' ); ?>
				<?php endif; ?>
			</div>
		</div>
		<?php
		// Inline CSS + JS for the size-picker buttons — registered via dummy handles
		// so the plugin emits no raw <style>/<script> from PHP.
		wp_add_inline_style(
			'wp-admin',
			'.parcselect-pick-size:hover{border-color:#f97316!important;transform:translateY(-2px);box-shadow:0 4px 12px rgba(249,115,22,.2)}'
			. '.parcselect-pick-size:disabled{opacity:.5;cursor:not-allowed;transform:none}'
		);
		if ( ! wp_script_is( 'parcselect-label-pick-size', 'registered' ) ) {
			wp_register_script(
				'parcselect-label-pick-size',
				PARCSELECT_PLUGIN_URL . 'assets/js/parcselect-label-pick-size.js',
				array(),
				PARCSELECT_VERSION,
				true
			);
		}
		// WP.org Plugin Check: skrypt ladowany wylacznie przez API kolejki
		// (wp_register/enqueue + wp_add_inline_script). Metabox renderuje sie
		// przed admin_footer, wiec late-enqueue dziala — surowy <script> zbedny.
		wp_enqueue_script( 'parcselect-label-pick-size' );
		wp_add_inline_script(
			'parcselect-label-pick-size',
			'window.PARCSELECT_PICK_SIZE = { nonce: ' . wp_json_encode( $nonce ) . ', orderId: ' . (int) $order_id . ' };',
			'before'
		);
	}

	/**
	 * Accordion with every label ever generated for this order. Mirrors the
	 * mechanism used by PARCEL SELECTOR PRO (class-etykietownik-metabox.php
	 * render_history, lines 977-1160) — <details><summary> collapsible with
	 * timeline of events per label entry.
	 *
	 * History is stored as `_ps_label_history` array:
	 *   [ { created_at, tracking, label_url, txid, crypto, amount, carrier, size }, ... ]
	 */
	private static function render_history_section( $order ) {
		$history = (array) $order->get_meta( '_ps_label_history', true );
		// Back-compat: if we have latest scalar meta but no history array,
		// synthesize one entry so old orders still render in the accordion.
		if ( empty( $history ) && $order->get_meta( '_ps_label_tracking_number', true ) ) {
			$history = array( array(
				'tracking'   => (string) $order->get_meta( '_ps_label_tracking_number', true ),
				'label_url'  => (string) $order->get_meta( '_ps_label_url', true ),
				'txid'       => (string) $order->get_meta( '_ps_label_txid', true ),
				'crypto'     => (string) $order->get_meta( '_ps_label_crypto', true ),
				'amount'     => (string) $order->get_meta( '_ps_label_crypto_amount', true ),
				'carrier'    => (string) $order->get_meta( '_ps_label_carrier', true ),
				'created_at' => (string) $order->get_meta( '_ps_label_completed_at', true ),
			) );
		}
		if ( empty( $history ) ) return;
		// Newest first.
		usort( $history, function ( $a, $b ) { return strcmp( (string) ( $b['created_at'] ?? '' ), (string) ( $a['created_at'] ?? '' ) ); } );
		?>
		<details class="ps-label-history" style="margin-top:14px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc">
			<summary style="cursor:pointer;padding:10px 14px;font-weight:700;color:#1e293b;font-size:13px;user-select:none;list-style:none;display:flex;align-items:center;justify-content:space-between">
				<span>&#128196; <?php echo esc_html( sprintf( _n( 'Historia etykiet (%d)', 'Historia etykiet (%d)', count( $history ), 'parcel-selector' ), count( $history ) ) ); ?></span>
				<span style="font-size:11px;color:#64748b">&#x25BC;</span>
			</summary>
			<div style="padding:4px 14px 12px">
				<?php foreach ( $history as $i => $h ) :
					$tr      = (string) ( $h['tracking'] ?? '' );
					$url     = self::fresh_label_url( $h['label_url'] ?? '' );
					$tx      = (string) ( $h['txid'] ?? '' );
					$crypto  = strtolower( (string) ( $h['crypto'] ?? '' ) );
					$amt     = (string) ( $h['amount'] ?? '' );
					$carr    = strtolower( (string) ( $h['carrier'] ?? '' ) );
					$when    = (string) ( $h['created_at'] ?? '' );
					$track_u = ( 'inpost' === $carr && $tr ) ? 'https://inpost.pl/sledzenie-przesylek?number=' . rawurlencode( $tr ) : '';
					$expl_u  = '';
					if ( $tx ) {
						if ( 'sol' === $crypto ) $expl_u = 'https://solscan.io/tx/' . rawurlencode( $tx );
						elseif ( 'btc' === $crypto ) $expl_u = 'https://mempool.space/tx/' . rawurlencode( $tx );
						elseif ( 'eth' === $crypto ) $expl_u = 'https://etherscan.io/tx/' . rawurlencode( $tx );
					}
					?>
					<div style="padding:10px 12px;margin-top:<?php echo 0 === $i ? '0' : '6px'; ?>;background:#fff;border:1px solid #e2e8f0;border-radius:6px;font-size:12px;line-height:1.6">
						<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
							<strong style="color:#065f46">#<?php echo (int) ( count( $history ) - $i ); ?> <?php echo 0 === $i ? '&middot; <span style="color:#10b981">najnowsza</span>' : ''; ?></strong>
							<span style="color:#64748b;font-size:11px"><?php echo esc_html( $when ); ?></span>
						</div>
						<?php if ( $tr ) : ?>
							<div><strong>Tracking:</strong>
								<?php if ( $track_u ) : ?>
									<a href="<?php echo esc_url( $track_u ); ?>" target="_blank" rel="noopener" style="font-family:monospace;color:#047857;text-decoration:underline"><?php echo esc_html( $tr ); ?> &#8599;</a>
								<?php else : ?>
									<code><?php echo esc_html( $tr ); ?></code>
								<?php endif; ?>
							</div>
						<?php endif; ?>
						<?php if ( $tx ) : ?>
							<div><strong>TXID:</strong>
								<?php if ( $expl_u ) : ?>
									<a href="<?php echo esc_url( $expl_u ); ?>" target="_blank" rel="noopener" style="font-family:monospace;color:#047857;text-decoration:underline;font-size:11px"><?php echo esc_html( substr( $tx, 0, 10 ) . '…' . substr( $tx, -8 ) ); ?> &#8599;</a>
								<?php else : ?>
									<code style="font-size:11px"><?php echo esc_html( substr( $tx, 0, 18 ) ); ?>…</code>
								<?php endif; ?>
								<?php if ( $amt ) : ?>
									&middot; <strong><?php echo esc_html( $amt ); ?> <?php echo esc_html( strtoupper( $crypto ?: 'SOL' ) ); ?></strong>
								<?php endif; ?>
							</div>
						<?php endif; ?>
						<?php if ( $url ) : ?>
							<div style="margin-top:6px">
								<a href="<?php echo esc_url( $url ); ?>" target="_blank" rel="noopener" style="display:inline-block;padding:4px 10px;background:#10b981;color:#fff;text-decoration:none;border-radius:4px;font-size:11px;font-weight:600">&#128196; PDF</a>
							</div>
						<?php endif; ?>
					</div>
				<?php endforeach; ?>
			</div>
		</details>
		<?php
	}

	/**
	 * AJAX: save consent step (called by parent JS bridge after iframe postMessage).
	 */
	public static function ajax_accept_consent() {
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$step     = isset( $_POST['step'] ) ? sanitize_key( wp_unslash( $_POST['step'] ) ) : '';
		$order    = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order not found' ), 404 );
		}
		if ( 'service' === $step ) {
			$order->update_meta_data( '_ps_label_consent', 'yes' );
		// v4.2 SPEC/11 A.4 — write_first_acceptance do consent_log.json (lokalny audit klienta)
		if ( class_exists( 'Parcselect_Label_Consent_Log' ) ) {
			$_existing_log = Parcselect_Label_Consent_Log::read_main();
			if ( null === $_existing_log || empty( $_existing_log['first_acceptance'] ) ) {
				Parcselect_Label_Consent_Log::write_first_acceptance( array(
					'wp_user_id'    => get_current_user_id(),
					'wp_user_login' => wp_get_current_user() ? wp_get_current_user()->user_login : '',
					'remote_ip'     => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
					'user_agent'    => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
					'url_at_time'   => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
				) );
			}
			// Per-order snapshot — kazda akceptacja w kontekscie ordera.
			$_pi_id = (string) $order->get_meta( '_ps_label_pi_id', true );
			Parcselect_Label_Consent_Log::write_per_order( $order->get_id(), $_pi_id, array(
				'wp_user_id' => get_current_user_id(),
				'remote_ip'  => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
				'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
			) );
		}
			$order->update_meta_data( '_ps_label_consent_date', current_time( 'mysql' ) );
			$order->update_meta_data( '_ps_label_consent_user', get_current_user_id() );
		} elseif ( 'sender' === $step ) {
			$order->update_meta_data( '_ps_label_sender_confirmed', 'yes' );
			$order->update_meta_data( '_ps_label_sender_confirmed_date', current_time( 'mysql' ) );
		} else {
			wp_send_json_error( array( 'message' => 'invalid step' ), 400 );
		}
		$order->save();
		// Claim this order for the current installation so the next render
		// does not treat the consent meta as cross-install leftover and purge it.
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'consent.saved', array( 'step' => $step ) );
		}
		wp_send_json_success( array( 'step' => $step ) );
	}

	/**
	 * AJAX: save sender address (global defaults) + confirm sender step.
	 */
	public static function ajax_accept_receiver() {
		// v4.2.2 BEZWZGLEDNY consent gate — ajax_accept_receiver
		if ( class_exists( 'Parcselect_Label_Consent_Gate' ) ) {
			$consent_check = Parcselect_Label_Consent_Gate::require_consent_or_fail();
			if ( is_wp_error( $consent_check ) ) {
				wp_send_json_error( array(
					'message' => $consent_check->get_error_code(),
					'gate'    => 'consent_required',
				), 403 );
				return;
			}
		}
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$order    = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order_not_found' ), 404 );
		}
		$order->update_meta_data( '_ps_label_receiver_confirmed', 'yes' );
		$order->update_meta_data( '_ps_label_receiver_confirmed_date', current_time( 'mysql' ) );
		$order->update_meta_data( '_ps_label_receiver_confirmed_user', get_current_user_id() );
		$order->save();
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}
		wp_send_json_success( array( 'accepted' => true ) );
	}

	public static function ajax_save_sender() {
		// v4.2.2 BEZWZGLEDNY consent gate — ajax_save_sender
		if ( class_exists( 'Parcselect_Label_Consent_Gate' ) ) {
			$consent_check = Parcselect_Label_Consent_Gate::require_consent_or_fail();
			if ( is_wp_error( $consent_check ) ) {
				wp_send_json_error( array(
					'message' => $consent_check->get_error_code(),
					'gate'    => 'consent_required',
				), 403 );
				return;
			}
		}
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$order    = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order not found' ), 404 );
		}
		// NS-59 2026-04-30: dodano sender_locker_id (paczkomat nadawczy InPost) +
		// save_as_default flag (checkbox "Zapisz dane wysyłającego jako domyślne").
		$fields   = array( 'sender_name', 'sender_company', 'sender_street', 'sender_city', 'sender_postcode', 'sender_phone', 'sender_email', 'sender_locker_id' );
		$required = array( 'sender_name', 'sender_street', 'sender_city', 'sender_postcode', 'sender_phone' );
		$values   = array();
		foreach ( $fields as $f ) {
			$raw = isset( $_POST[ $f ] ) ? sanitize_text_field( wp_unslash( $_POST[ $f ] ) ) : '';
			if ( 'sender_locker_id' === $f ) {
				// Walidacja: 5-6 alfanumerycznych InPost format (np. WAW01M)
				$cleaned = strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', (string) $raw ) );
				$values[ $f ] = ( '' === $cleaned || preg_match( '/^[A-Z]{3}\d{2}[A-Z]?$/', $cleaned ) ) ? $cleaned : '';
			} else {
				$values[ $f ] = sanitize_text_field( $raw );
			}
		}
		$missing = array();
		foreach ( $required as $f ) {
			if ( empty( $values[ $f ] ) ) {
				$missing[] = $f;
			}
		}
		if ( ! empty( $missing ) ) {
			wp_send_json_error( array( 'message' => 'missing fields', 'fields' => $missing ), 400 );
		}
		// NS-62 2026-04-30: walidacja sender_name (Furgonetka /pickup/name notFull regex).
		// Wymagamy: ≥2 słowa, tylko litery/spacje/myślniki, BRAK cyfr i znaków specjalnych.
		// Sklepy ktore wpisuja "Carbom" (1 slowo) lub "Sklep #5" (cyfry) failed CEB.
		$_sn = trim( (string) $values['sender_name'] );
		if ( ! preg_match( '/^[\p{L}\s\-]+\s+[\p{L}\s\-]+$/u', $_sn ) ) {
			wp_send_json_error( array(
				'message' => 'sender_name_invalid',
				'detail'  => __( 'The "Sender name" field must contain a FIRST and LAST NAME (at least 2 words, letters only, no digits or special characters). Example: "Jan Kowalski".', 'parcel-selector' ),
				'fields'  => array( 'sender_name' ),
			), 400 );
		}
		// Save to WP options jako domyślne TYLKO gdy checkbox "Zapisz jako domyślne" zaznaczony.
		// Domyślnie checkbox jest ON, ale klient może odznaczyć (np. one-off label z innego nadawcy).
		$_sad = isset( $_POST['save_as_default'] ) ? sanitize_key( wp_unslash( $_POST['save_as_default'] ) ) : null;
		$save_as_default = ( null === $_sad ) ? true : ( '1' === $_sad || 'true' === $_sad );
		if ( $save_as_default ) {
			foreach ( $fields as $f ) {
				update_option( 'parcselect_label_' . $f, $values[ $f ], false );
				// NS-59: synchronizuj też do parcselect_<f> (admin panel uses tego klucza)
				update_option( 'parcselect_' . $f, $values[ $f ], false );
			}
		}
		// Per-order zapis ZAWSZE (nawet jeśli nie domyślne — żeby ten konkretny order używał tych danych)
		foreach ( $fields as $f ) {
			$order->update_meta_data( '_ps_label_' . $f, $values[ $f ] );
		}
		// Confirm for this order.
		$order->update_meta_data( '_ps_label_sender_confirmed', 'yes' );
		$order->update_meta_data( '_ps_label_sender_confirmed_date', current_time( 'mysql' ) );
		$order->save();
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'sender.saved', array( 'fields' => count( $fields ) ) );
		}
		wp_send_json_success( array( 'saved' => true ) );
	}

	/**
	 * v4.1 (2026-04) — Krok 2 rendered from Oracle-signed payload.
	 *
	 * Pure renderer: reads `_ps_label_pscp_payload` JSON meta and echoes HTML
	 * derived entirely from the payload fields. NO math, NO translation,
	 * NO rate fetching, NO QR generation library — Oracle/ps-cryptopay
	 * supplied every string + Solana Pay URI; qrcode.min.js (bundled with
	 * the plugin) renders the QR pixel-art from the URI.
	 *
	 * Expected payload shape (post-A2, see A1_A2_RUNBOOK_DIAGNOSTIC.md §1):
	 *   amount, amount_base, amount_dust, dust_id, coin, coin_label, coin_color,
	 *   wallet_address, wallet_prefix, wallet_suffix, wallet_warn_text,
	 *   memo, qr_uri, rate, rate_sources[], rate_sources_count, rate_confidence,
	 *   rate_locked, expires_at, created_at, step_current, step_labels,
	 *   i18n{locale, send_exactly, to_address, time_remaining, ...},
	 *   explorer_account, copy_address_text, copy_amount_text, signature.
	 *
	 * @param WC_Order $order
	 * @param string   $payload_json  raw JSON from _ps_label_pscp_payload meta
	 */
	public static function render_from_pscp_payload( $order, $payload_json, $is_paid = false ) {
		$session_id = ''; // P1 #6 2026-05-14: pre-init for PHP 8.x undefined-var warning (linia 1257 use in mailto fallback)
		$order_id = $order->get_id();
		$p        = json_decode( $payload_json, true );

		if ( ! is_array( $p ) || empty( $p['wallet_address'] ) || empty( $p['amount'] ) ) {
			echo '<p style="padding:20px;color:#dc2626;">' . esc_html__( 'Error: the payment payload is missing or corrupted.', 'parcel-selector' ) . ' ' .
			     '<a href="' . esc_url( admin_url( 'admin.php?page=wc-orders&action=edit&id=' . $order_id ) ) . '">' . esc_html__( 'Refresh the page', 'parcel-selector' ) . '</a> ' .
			     esc_html__( 'or generate a new session.', 'parcel-selector' ) . '</p>';
			return;
		}

		// Fail-closed: payload MUST belong to this order's pi_id
		$our_pi = (string) $order->get_meta( '_ps_label_pi_id', true );
		if ( '' !== $our_pi && isset( $p['pi_id'] ) && (string) $p['pi_id'] !== $our_pi ) {
			echo '<p style="padding:20px;color:#dc2626;">' . esc_html__( 'Error: the payload does not match the pi_id of this order.', 'parcel-selector' ) . '</p>';
			return;
		}

		$nonce       = wp_create_nonce( 'parcselect_label_consent' );
		$qrcode_js   = plugins_url( 'PS_LABEL/assets/js/qrcode.min.js', dirname( __DIR__ ) . '/parcel-selector.php' );

		$i18n        = is_array( $p['i18n'] ?? null ) ? $p['i18n'] : array();
		$step_labels = is_array( $p['step_labels'] ?? null ) ? $p['step_labels'] : array( __( 'Send payment', 'parcel-selector' ), __( 'Blockchain verification', 'parcel-selector' ), __( 'Confirmed!', 'parcel-selector' ) );
		$step_cur    = (int) ( $p['step_current'] ?? 1 );
		// NS-48 fix 2026-04-30: $is_paid=true znaczy ZE Oracle wykryl TX i oznaczyl paid.
		// Wcześniej stepper zwiększał TYLKO do 2 ("Weryfikacja blockchain") — wprowadzało użytkownika w błąd że
		// transakcja jest jeszcze weryfikowana. Po paid + _ps_label_status=completed (webhook OUT z CEB)
		// step powinien być 3 (Potwierdzone!). Plus jeśli mamy też _ps_label_url (PDF gotowy) — krok 3 finalny.
		$_local_status = (string) $order->get_meta( '_ps_label_status', true );
		$_local_label_url = (string) $order->get_meta( '_ps_label_url', true );
		if ( $is_paid && ( 'completed' === $_local_status || ! empty( $_local_label_url ) ) ) {
			$step_cur = 3; // Etykieta gotowa
		} elseif ( $is_paid ) {
			$step_cur = 2; // Paid ale czekamy na PDF z CEB
		}
		$_paid_txid = $is_paid ? (string) $order->get_meta( '_ps_label_txid', true ) : '';
		$_paid_at   = $is_paid ? (string) $order->get_meta( '_ps_label_paid_at', true ) : '';
		$coin_color  = (string) ( $p['coin_color'] ?? '#9945FF' );
		$amount      = (string) $p['amount'];
		$amount_base = (string) ( $p['amount_base'] ?? $amount );
		$dust_id     = (int)    ( $p['dust_id'] ?? 0 );
		$wallet      = (string) $p['wallet_address'];
		$wallet_pfx  = (string) ( $p['wallet_prefix'] ?? substr( $wallet, 0, 4 ) );
		$wallet_sfx  = (string) ( $p['wallet_suffix'] ?? substr( $wallet, -4 ) );
		$qr_uri      = (string) ( $p['qr_uri'] ?? '' );
		$memo        = (string) ( $p['memo'] ?? '' );
		$rate        = (string) ( $p['rate'] ?? '' );
		$rate_fiat   = (string) ( $p['rate_fiat'] ?? 'PLN' );
		$rate_sources = is_array( $p['rate_sources'] ?? null ) ? $p['rate_sources'] : array();
		$rate_count  = (int) ( $p['rate_sources_count'] ?? count( $rate_sources ) );
		$rate_conf   = (string) ( $p['rate_confidence'] ?? 'high' );
		$rate_locked = ! empty( $p['rate_locked'] );
		$expires_at  = (string) ( $p['expires_at'] ?? '' );
		$price_fd    = (string) ( $p['price_fiat_display'] ?? ( ( $p['price_fiat'] ?? '' ) . ' ' . ( $p['fiat_currency'] ?? '' ) ) );
		$wallet_warn = (string) ( $p['wallet_warn_text'] ?? '' );
		$explorer    = (string) ( $p['explorer_account'] ?? '' );
		$copy_addr   = (string) ( $p['copy_address_text'] ?? $wallet );
		$copy_amt    = (string) ( $p['copy_amount_text'] ?? $amount );

		$t = function( $k, $fallback = '' ) use ( $i18n ) {
			return esc_html( (string) ( $i18n[ $k ] ?? $fallback ) );
		};
		?>
		<!-- NS-65 2026-04-30: flex wrapper — parcsepa-krok2 (Stage 4) + parcsepa-generating-label (Stage 5 placeholder).
		     Po SOL paid → JS pokazuje generating-label jako sąsiadującą kolumnę po prawej. -->
		<div class="parcselect-stage4-wrap" style="display:flex;gap:14px;align-items:stretch;flex-wrap:wrap;margin:10px 0;">
		<div id="parcselect-krok2" data-oracle-payload="v4.1"
		     style="flex:1 1 320px;min-width:280px;padding:14px;background:linear-gradient(135deg,#fff7ed 0%,#ffedd5 50%,#fef3c7 100%);
		            border:2px solid <?php echo esc_attr( $coin_color ); ?>;border-radius:12px;
		            box-sizing:border-box;
		            font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">

			<!-- STEPPER -->
			<div class="parcselect-stepper" style="display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:14px;padding:0 8px">
				<?php for ( $i = 0; $i < count( $step_labels ); $i++ ) :
					$done = $i + 1 < $step_cur;
					$active = $i + 1 === $step_cur;
					$bg = $done ? '#10b981' : ( $active ? $coin_color : '#e2e8f0' );
					$fg = ( $done || $active ) ? '#fff' : '#94a3b8';
					$text_color = ( $done || $active ) ? '#1e293b' : '#94a3b8';
				?>
					<div style="display:flex;align-items:center;gap:8px">
						<div style="width:32px;height:32px;border-radius:50%;background:<?php echo esc_attr( $bg ); ?>;
						            color:<?php echo esc_attr( $fg ); ?>;display:flex;align-items:center;justify-content:center;
						            font-weight:800;font-size:13px;flex-shrink:0">
							<?php echo $done ? '&#10003;' : (int) ( $i + 1 ); ?>
						</div>
						<div style="font-size:12px;color:<?php echo esc_attr( $text_color ); ?>;font-weight:<?php echo ( $done || $active ) ? 700 : 500; ?>">
							<?php echo esc_html( (string) $step_labels[ $i ] ); ?>
						</div>
					</div>
					<?php if ( $i < count( $step_labels ) - 1 ) : ?>
					<div style="width:28px;height:2px;background:<?php echo $done ? '#10b981' : '#e2e8f0'; ?>;margin:0 8px;flex-shrink:0"></div>
					<?php endif; ?>
				<?php endfor; ?>
			</div>

			<!-- PROGRESS + TIMER -->
			<?php if ( 1 === $step_cur && '' !== $expires_at ) : ?>
			<div class="parcselect-progress-wrap" style="margin-bottom:14px">
				<div style="width:100%;height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden">
					<div id="parcselect-progress-fill" style="height:100%;width:100%;background:#10b981;transition:width 1s linear,background .5s"></div>
				</div>
				<div style="display:flex;justify-content:space-between;margin-top:6px;font-size:12px">
					<span style="color:#64748b"><?php echo esc_html( $t( 'time_remaining', __( 'Time remaining', 'parcel-selector' ) ) ); ?></span>
					<span id="parcselect-timer" data-expires-at="<?php echo esc_attr( $expires_at ); ?>"
					      style="font-family:'Courier New',monospace;font-weight:700;color:#10b981">--:--</span>
				</div>
			</div>
			<?php endif; ?>

			<!-- NS-42 fix 2026-04-30: Single-column layout per directive uzytkownika.
			     Order: HERO AMOUNT (top, najistotniejsze) → QR centered → WALLET ADDRESS pod QR
			     → PRIMARY CTA (deeplink) → Copy buttons → Explorer → Warning + Memo + Status (bottom). -->
			<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:18px;
			            box-shadow:0 1px 3px rgba(0,0,0,.04);width:100%;box-sizing:border-box">

				<!-- ━━━━━━━━━━━━━━ 1. HERO AMOUNT (TOP — NAJISTOTNIEJSZE) ━━━━━━━━━━━━━━ -->
				<div style="text-align:center;padding:14px 12px 18px;border-bottom:2px solid #f1f5f9;margin-bottom:18px">
					<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:1px;font-weight:700;margin-bottom:8px">
						<?php echo esc_html( $t( 'send_exactly', __( 'Send exactly', 'parcel-selector' ) ) ); ?>
					</div>
					<div style="font-family:'Courier New',monospace;font-size:36px;font-weight:900;color:#0f172a;line-height:1.1;margin-bottom:6px;word-break:break-all">
						<?php echo esc_html( $amount ); ?> <span style="color:<?php echo esc_attr( $coin_color ); ?>"><?php echo esc_html( strtoupper( $p['coin'] ?? 'SOL' ) ); ?></span>
					</div>
					<div style="font-size:20px;font-weight:800;color:<?php echo esc_attr( $coin_color ); ?>;margin-bottom:10px">
						= <?php echo esc_html( $price_fd ); ?>
					</div>
					<?php if ( '' !== $rate ) : ?>
					<div style="font-size:13px;color:#475569;margin-bottom:8px">
						1 <?php echo esc_html( strtoupper( $p['coin'] ?? 'SOL' ) ); ?> = <strong><?php echo esc_html( $rate ); ?></strong> <?php echo esc_html( $rate_fiat ); ?>
					</div>
					<?php endif; ?>
					<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;justify-content:center">
						<?php foreach ( $rate_sources as $src ) : if ( ! is_array( $src ) || empty( $src['name'] ) ) continue; ?>
						<span style="display:inline-block;padding:3px 10px;background:#dbeafe;color:#1e40af;border-radius:10px;font-size:11px;font-weight:700"><?php echo esc_html( $src['name'] ); ?></span>
						<?php endforeach; ?>
						<span style="display:inline-block;padding:3px 10px;background:#d1fae5;color:#065f46;border-radius:10px;font-size:11px;font-weight:700">
							<?php /* translators: %d = number of price rate sources */ echo esc_html( sprintf( ( $i18n['sources_badge'] ?? __( '%d sources', 'parcel-selector' ) ), $rate_count ) ); ?>
						</span>
						<?php if ( $rate_locked ) : ?>
						<span style="display:inline-block;padding:3px 10px;background:#fef3c7;color:#78350f;border-radius:10px;font-size:11px;font-weight:700">🔒 <?php echo esc_html( $i18n['rate_locked'] ?? __( 'rate locked', 'parcel-selector' ) ); ?></span>
						<?php endif; ?>
						<span style="display:inline-block;padding:3px 10px;background:#f1f5f9;color:#475569;border-radius:10px;font-size:11px"><?php echo esc_html( $rate_conf ); ?></span>
					</div>
					<?php if ( $dust_id > 0 ) : ?>
					<div style="font-size:11px;color:#9a3412;margin-top:10px;padding-top:8px;border-top:1px dashed #fed7aa">
						<?php esc_html_e( 'Base:', 'parcel-selector' ); ?> <code style="background:#fffbeb;padding:1px 6px;border-radius:3px"><?php echo esc_html( $amount_base ); ?></code>
						<span style="color:<?php echo esc_attr( $coin_color ); ?>;font-weight:700">+ dust ID <?php echo esc_html( str_pad( (string) $dust_id, 6, '0', STR_PAD_LEFT ) ); ?></span>
						<span style="color:#64748b"><?php esc_html_e( '(unique payment identifier)', 'parcel-selector' ); ?></span>
					</div>
					<?php endif; ?>
				</div>

				<!-- ━━━━━━━━━━━━━━ 2. QR CODE (centered) ━━━━━━━━━━━━━━ -->
				<div style="text-align:center;margin-bottom:14px">
					<div style="display:inline-block;padding:5px 16px;background:<?php echo esc_attr( $coin_color ); ?>;color:#fff;
					            border-radius:999px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px">
						<?php echo esc_html( $p['coin_label'] ?? 'SOLANA (SOL)' ); ?>
					</div>
					<div id="parcselect-qr-canvas"
					     style="background:#fff;padding:10px;border:1px solid #e5e7eb;border-radius:10px;
					            width:100%;max-width:248px;aspect-ratio:1/1;margin:0 auto;box-sizing:border-box"></div>
					<div style="font-size:12px;color:#64748b;margin-top:10px;font-weight:600">
						<?php echo esc_html( $t( 'scan_qr', __( 'Scan the QR code to pay', 'parcel-selector' ) ) ); ?>
					</div>
					<div style="font-size:11px;color:#94a3b8;margin-top:2px">
						<?php echo esc_html( $t( 'wallet_hint', 'Phantom, Solflare, Backpack…' ) ); ?>
					</div>
				</div>

				<!-- ━━━━━━━━━━━━━━ 3. WALLET ADDRESS (POD QR) ━━━━━━━━━━━━━━ -->
				<div style="margin-bottom:14px">
					<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.5px;font-weight:700;margin-bottom:6px;text-align:center">
						<?php echo esc_html( $t( 'to_address', __( 'To this address', 'parcel-selector' ) ) ); ?>
					</div>
					<input type="text" readonly value="<?php echo esc_attr( $wallet ); ?>"
					       onclick="this.select()" title="<?php echo esc_attr__( 'Click to select the whole address', 'parcel-selector' ); ?>"
					       style="width:100%;padding:10px 12px;font-family:'Courier New',monospace;font-size:13px;
					              letter-spacing:0;border:1.5px solid #fed7aa;background:#fffbeb;border-radius:8px;color:#9a3412;
					              box-sizing:border-box;text-align:center;font-weight:600;min-width:0" />
				</div>

				<!-- ━━━━━━━━━━━━━━ 4. PRIMARY CTA + COPY BUTTONS ━━━━━━━━━━━━━━ -->
				<!-- NS-43: Spec-compliance — uzyj i18n.open_in_wallet zamiast hardcoded.
				     coin_gradient brak w payload Oracle (TODO v4.3.8), buduje fallback z coin_color. -->
				<?php
				$coin_gradient = isset( $p['coin_gradient'] )
					? (string) $p['coin_gradient']
					: 'linear-gradient(135deg,' . $coin_color . ' 0%,#14F195 100%)';
				$open_label = (string) ( $i18n['open_in_wallet'] ?? __( 'Open in wallet →', 'parcel-selector' ) );
				$cta_helper = (string) ( $i18n['cta_helper'] ?? __( 'The full Solana Pay URI contains the address, amount and memo. Works with Phantom, Solflare, Backpack.', 'parcel-selector' ) );
				?>
				<?php if ( '' !== $qr_uri ) : ?>
				<a href="<?php echo esc_url( $qr_uri ); ?>"
				   style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:13px 10px;
				          background:<?php echo esc_attr( $coin_gradient ); ?>;color:#fff;border-radius:10px;
				          text-align:center;font-weight:700;font-size:14px;text-decoration:none;margin-bottom:10px;
				          box-shadow:0 4px 12px rgba(0,0,0,0.18);box-sizing:border-box;
				          letter-spacing:0;line-height:1.2;transition:transform .15s,box-shadow .15s"
				   onmouseover="this.style.transform='translateY(-1px)';this.style.boxShadow='0 6px 18px rgba(0,0,0,0.28)'"
				   onmouseout="this.style.transform='';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.18)'"
				   title="<?php echo esc_attr( $cta_helper ); ?>">
					💳 <?php echo esc_html( $open_label ); ?>
				</a>
				<div style="font-size:10px;color:#9ca3af;text-align:center;margin-bottom:12px;line-height:1.4">
					<?php echo esc_html( $cta_helper ); ?>
				</div>
				<?php endif; ?>

				<!-- NS-44: 2x2 grid buttonów (zamiast flex wrap który wyciekał na waskich metabox).
				     URI button na nowym rzędzie zamiast 3 w jednej linii. -->
				<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
					<button type="button" class="parcselect-copy-btn"
					        data-copy="<?php echo esc_attr( $copy_addr ); ?>"
					        style="padding:10px 8px;background:#f97316;color:#fff;border:none;
					               border-radius:8px;font-weight:700;font-size:13px;cursor:pointer;line-height:1.2">
						<?php echo esc_html( $t( 'copy_address', __( 'Copy address', 'parcel-selector' ) ) ); ?>
					</button>
					<button type="button" class="parcselect-copy-btn"
					        data-copy="<?php echo esc_attr( $copy_amt ); ?>"
					        style="padding:10px 8px;background:#f97316;color:#fff;border:none;
					               border-radius:8px;font-weight:700;font-size:13px;cursor:pointer;line-height:1.2">
						<?php echo esc_html( $t( 'copy_amount', __( 'Copy amount', 'parcel-selector' ) ) ); ?>
					</button>
					<button type="button" class="parcselect-copy-btn"
					        data-copy="<?php echo esc_attr( $qr_uri ); ?>"
					        style="grid-column:1 / -1;padding:10px 8px;background:#7c3aed;color:#fff;border:none;
					               border-radius:8px;font-weight:700;font-size:13px;cursor:pointer;line-height:1.2"
					        title="<?php echo esc_attr( $i18n['copy_uri_hint'] ?? __( 'Full Solana Pay URI with amount and memo', 'parcel-selector' ) ); ?>">
						📋 <?php echo esc_html( $i18n['copy_uri'] ?? __( 'Copy URI (with amount)', 'parcel-selector' ) ); ?>
					</button>
				</div>
				<?php if ( '' !== $explorer ) : ?>
				<a href="<?php echo esc_url( $explorer ); ?>" target="_blank" rel="noopener"
				   style="display:block;padding:10px 14px;background:#2563eb;color:#fff;border-radius:8px;
				          text-align:center;font-weight:700;font-size:13px;text-decoration:none;margin-bottom:14px">
					<?php echo esc_html( $t( 'explorer', 'Explorer' ) ); ?> →
				</a>
				<?php endif; ?>

				<!-- ━━━━━━━━━━━━━━ 5. WARNING + MEMO + STATUS (NA DOLE) ━━━━━━━━━━━━━━ -->
				<?php if ( '' !== $wallet_warn ) : ?>
				<div style="padding:12px 14px;background:#fef3c7;border:1px solid #fbbf24;border-radius:8px;
				            color:#92400e;font-size:12px;line-height:1.5;margin-bottom:10px">
					<?php echo esc_html( $wallet_warn ); ?>
				</div>
				<?php endif; ?>

				<?php if ( '' !== $memo ) : ?>
				<div style="font-size:11px;color:#64748b;margin-bottom:10px;text-align:center">
					<strong>Memo:</strong>
					<code style="background:#f1f5f9;padding:2px 8px;border-radius:4px;font-size:11px"><?php echo esc_html( $memo ); ?></code>
				</div>
				<?php endif; ?>

				<?php if ( $is_paid ) : ?>
				<div id="parcselect-status-line" style="padding:12px 14px;background:#ecfdf5;
				     border:2px solid #10b981;border-radius:8px;font-size:13px;color:#065f46;text-align:left;line-height:1.6">
					<div style="font-weight:800;font-size:14px;margin-bottom:6px">
						<span style="display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;background:#10b981;color:#fff;border-radius:50%;font-size:13px;margin-right:6px">&#10003;</span>
						<?php esc_html_e( 'Payment confirmed!', 'parcel-selector' ); ?>
					</div>
					<?php if ( '' !== $_paid_txid ) : $_txid_short = substr( $_paid_txid, 0, 10 ) . '…' . substr( $_paid_txid, -8 ); ?>
					<div style="margin-bottom:4px">
						<strong>TXID:</strong>
						<a href="<?php echo esc_url( 'https://solscan.io/tx/' . rawurlencode( $_paid_txid ) ); ?>" target="_blank" rel="noopener" style="font-family:monospace;color:#047857;text-decoration:underline;font-size:12px"><?php echo esc_html( $_txid_short ); ?> &#8599;</a>
					</div>
					<?php endif; ?>
					<?php if ( '' !== $_paid_at ) : ?>
					<div style="font-size:11px;color:#047857"><?php esc_html_e( 'Detected:', 'parcel-selector' ); ?> <?php echo esc_html( $_paid_at ); ?></div>
					<?php endif; ?>
					<div style="margin-top:8px;padding:8px 10px;background:#fff;border:1px solid #bae6fd;border-radius:6px;color:#0369a1;font-size:12px">
						<span id="parcselect-status-dot" style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3b82f6;margin-right:6px;vertical-align:middle;animation:parcsepa-pulse 1.5s ease-in-out infinite"></span>
						<?php esc_html_e( 'Generating the InPost label… (usually 15–60 s)', 'parcel-selector' ); ?>
					</div>
				</div>
				<?php else : ?>
				<div id="parcselect-status-line" style="padding:10px 12px;background:#f0f9ff;
				     border:1px solid #bae6fd;border-radius:8px;font-size:12px;color:#0369a1;text-align:center">
					<span id="parcselect-status-dot" style="display:inline-block;width:10px;height:10px;border-radius:50%;
					      background:#f59e0b;margin-right:8px;vertical-align:middle;animation:parcsepa-pulse 1.5s ease-in-out infinite"></span>
					<span id="parcselect-status-text"><?php echo esc_html( $t( 'waiting', __( 'Waiting for payment confirmation on the blockchain…', 'parcel-selector' ) ) ); ?></span>
				</div>
				<?php endif; ?>

				<!-- NS-54 v3 2026-04-30: hidden "Generujemy etykietę..." panel.
				     Pokazuje się gdy polling wykrył paid ale label_url jeszcze nie dotarło.
				     Animowane 3 kropki cyklicznie + counter sekund + emergency action po 180s. -->
				<!-- NS-56 2026-04-30: panel błędu kreacji etykiety.
				     Pokazuje się gdy CEB/Furgonetka odrzuciło order (status=error).
				     Zawiera jasną instrukcję kontaktu + email po zwrot środków. -->
				<div id="parcselect-label-error" style="display:none;margin-top:12px;padding:24px;
				     background:linear-gradient(135deg,#fef2f2 0%,#fee2e2 100%);
				     border:2px solid #dc2626;border-radius:10px;
				     box-shadow:0 4px 14px rgba(220,38,38,.22)">
					<div style="font-size:18px;font-weight:800;color:#7f1d1d;margin-bottom:10px;text-align:center">
						&#9888;&#65039; <?php esc_html_e( 'We could not generate the label', 'parcel-selector' ); ?>
					</div>
					<div style="font-size:13px;color:#991b1b;line-height:1.6;margin-bottom:14px;text-align:center">
						<?php echo wp_kses( __( 'The payment was recorded on the blockchain, but within <strong>90 seconds</strong> our system was unable to generate the InPost label. <strong>We apologize.</strong>', 'parcel-selector' ), array( 'strong' => array() ) ); ?>
					</div>

					<?php
					$_FP_pi      = (string) $order->get_meta( '_ps_label_pi_id', true );
					$_FP_txid    = (string) $order->get_meta( '_ps_label_txid', true );
					$_FP_wallet  = (string) $order->get_meta( '_ps_label_sol_wallet', true );
					$_FP_memo    = (string) $order->get_meta( '_ps_label_memo', true );
					$_FP_paid_at = (string) $order->get_meta( '_ps_label_paid_at', true );
					$_FP_pln     = (string) $order->get_meta( '_ps_label_price_pln', true );
					$_FP_sol     = (string) $order->get_meta( '_ps_label_sol_amount', true );
					$_FP_shop    = home_url();
					$_FP_text    = sprintf(
						"Order: #%d" . "\n" . "PID: %s" . "\n" . "Amount: %s PLN / %s SOL" . "\n" . "TxID: %s" . "\n" . "Wallet: %s" . "\n" . "Memo: %s" . "\n" . "Paid at (UTC): %s" . "\n" . "Shop: %s",
						$order_id, $_FP_pi, $_FP_pln, $_FP_sol, $_FP_txid, $_FP_wallet, $_FP_memo, $_FP_paid_at, $_FP_shop
					);
					$_FP_mailto_subj = rawurlencode( 'FAIL label - Order #' . $order_id );
					$_FP_mailto_body = rawurlencode( "Hello,\n\nPlease resolve the failed label generation for my order.\n\nIdentification data:\n\n" . $_FP_text . "\n\nRegards" );
					?>

					<div style="background:#fff;border:1px solid #fecaca;border-radius:8px;padding:14px;margin-bottom:14px;font-family:monospace;font-size:12px;line-height:1.85">
						<div style="font-weight:700;color:#7f1d1d;margin-bottom:8px;font-family:sans-serif">&#128203; <?php esc_html_e( 'Identification data (copy into the email):', 'parcel-selector' ); ?></div>
						<div><strong>Order ID:</strong> #<?php echo (int) $order_id; ?></div>
						<div><strong>PID:</strong> <?php echo esc_html( $_FP_pi ); ?></div>
						<div><strong><?php esc_html_e( 'Amount:', 'parcel-selector' ); ?></strong> <?php echo esc_html( $_FP_pln ); ?> PLN / <?php echo esc_html( $_FP_sol ); ?> SOL</div>
						<div style="word-break:break-all"><strong>TxID:</strong> <?php echo esc_html( $_FP_txid ); ?></div>
						<div style="word-break:break-all"><strong>Wallet:</strong> <?php echo esc_html( $_FP_wallet ); ?></div>
						<div><strong>Memo:</strong> <?php echo esc_html( $_FP_memo ); ?></div>
						<div><strong><?php esc_html_e( 'Paid at (UTC):', 'parcel-selector' ); ?></strong> <?php echo esc_html( $_FP_paid_at ); ?></div>
						<div><strong><?php esc_html_e( 'Shop:', 'parcel-selector' ); ?></strong> <?php echo esc_html( $_FP_shop ); ?></div>
					</div>

					<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center;margin-bottom:10px">
						<button type="button" id="parcselect-fail-copy"
							data-copy="<?php echo esc_attr( $_FP_text ); ?>"
							onclick="var t=this.dataset.copy;if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){var b=document.getElementById('parcselect-fail-copy');var o=b.textContent;b.textContent='&#10003; <?php echo esc_js( __( 'Copied', 'parcel-selector' ) ); ?>';setTimeout(function(){b.textContent=o;},2000);})}"
							style="padding:10px 18px;background:#fff;border:2px solid #dc2626;border-radius:6px;color:#7f1d1d;font-weight:700;cursor:pointer;font-size:13px">
							&#128203; <?php esc_html_e( 'Copy all data', 'parcel-selector' ); ?>
						</button>
						<a href="mailto:office@parcelselector.com?subject=<?php echo esc_attr( $_FP_mailto_subj ); ?>&body=<?php echo esc_attr( $_FP_mailto_body ); ?>"
							style="padding:10px 18px;background:#dc2626;color:#fff;text-decoration:none;border-radius:6px;font-weight:700;font-size:13px;display:inline-block">
							&#128231; office@parcelselector.com &rarr;
						</a>
					</div>

					<div id="parcselect-error-techdetails" style="display:none;font-size:11px;color:#7f1d1d;
					     padding:8px 10px;background:rgba(255,255,255,.6);border-radius:6px;
					     font-family:monospace;word-break:break-all;margin-bottom:8px">
						<strong><?php esc_html_e( 'Technical details:', 'parcel-selector' ); ?></strong> <span id="parcselect-error-msg"></span>
					</div>

					<div style="font-size:11px;color:#991b1b;text-align:center;line-height:1.5">
						<?php esc_html_e( 'Your Solana transaction is kept on-chain (TxID above) and can be verified.', 'parcel-selector' ); ?><br>
						<?php esc_html_e( 'Expect a reply within 24 hours.', 'parcel-selector' ); ?>
					</div>
				</div>

			</div>
			<!-- RATE BAR removed in NS-42 — rate info merged into HERO AMOUNT (top) -->
			<?php // intentionally no separate rate bar — NS-42 ?>

			<!-- Footer: generator info -->
			<div style="margin-top:10px;text-align:center;font-size:10px;color:#94a3b8">
				<?php echo esc_html( $p['generator'] ?? 'pscp/v4.1' ); ?> · <?php esc_html_e( 'Oracle-backed Step 2', 'parcel-selector' ); ?> · <?php esc_html_e( 'Polling every 10s', 'parcel-selector' ); ?>
			</div>

			<?php
			// FIX B 2026-05-14: gdy _ps_label_status='error' renderer auto-pokazuje FAIL panel
			// + ukrywa QR/wallet/amount. Stan zamrozony, bez retry, bez zmian stage 4.
			$_PSLE_status_meta = (string) $order->get_meta( '_ps_label_status', true );
			if ( 'error' === $_PSLE_status_meta ) :
			?>
				<?php
				// WP.org compliance: brak surowego <script> — auto-fail panel JS
				// przez wp_register_script (pusty src) + wp_enqueue_script + wp_add_inline_script.
				wp_register_script( 'parcselect-label-failpanel', '', array(), defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '1.0.0', true );
				wp_enqueue_script( 'parcselect-label-failpanel' );
				wp_add_inline_script( 'parcselect-label-failpanel', "(function(){if(window.__PSLE_auto_fail_done)return;window.__PSLE_auto_fail_done=true;var run=function(){var fail=document.getElementById('parcselect-label-error');if(fail)fail.style.display='block';['parcselect-qr-canvas','parcselect-amount-hero','parcselect-wallet-line','parcselect-wallet-buttons','parcselect-warning-box','parcselect-status-line'].forEach(function(_id){var _el=document.getElementById(_id);if(_el)_el.style.display='none';});var _hideSel=['.parcselect-payment-amount','.parcselect-payment-qr','.parcselect-payment-address','.parcselect-payment-wallet-buttons','.parcselect-payment-status'];_hideSel.forEach(function(_s){var _e=document.querySelector(_s);if(_e)_e.style.display='none';});};if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',run);else run();})();" );
				?>
			<?php endif; ?>
		</div>
		<!-- NS-65 2026-04-30: "Generujemy etykietę..." jako OSOBNA kolumna po prawej (5. col).
		     Style identyczne z Stage 1-3 done (#ecfdf5→#d1fae5 + #10b981 border). Pulsująca etykieta
		     placeholder + animowane kropki. NS-57: ZERO nazw 3rd-party suppliers (no Furgonetka link). -->
		<div id="parcselect-generating-label" style="display:none;flex:1 1 280px;min-width:260px;padding:20px;
		     background:linear-gradient(135deg,#ecfdf5 0%,#d1fae5 100%);
		     border:2px solid #10b981;border-radius:14px;text-align:center;
		     box-shadow:0 4px 14px rgba(16,185,129,.18);position:relative;
		     animation:psle-genlabel-pulse 2.4s ease-in-out infinite;">
			<div style="font-size:16px;font-weight:800;color:#065f46;margin-bottom:8px;line-height:1.3">
				🏷️ <?php esc_html_e( 'Generating the label', 'parcel-selector' ); ?><span class="parcselect-dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span>
			</div>
			<div style="margin:14px auto;width:80%;max-width:160px;aspect-ratio:105/148;background:rgba(255,255,255,.7);
			     border:2px dashed #10b981;border-radius:8px;display:flex;align-items:center;justify-content:center;
			     animation:psle-genlabel-placeholder-pulse 1.6s ease-in-out infinite;color:#059669;font-size:34px;">📦</div>
			<div style="font-size:12px;color:#047857;line-height:1.5;margin-top:10px;">
				<?php esc_html_e( 'InPost label — usually 15–60 seconds.', 'parcel-selector' ); ?><br>
				<?php esc_html_e( 'We will open the PDF automatically once finished.', 'parcel-selector' ); ?>
			</div>
			<div style="font-size:11px;color:#059669;margin-top:10px;font-family:monospace;background:rgba(255,255,255,.6);
			     padding:6px 10px;border-radius:6px;">
				⏱ <?php esc_html_e( 'Waiting for', 'parcel-selector' ); ?> <span id="parcselect-genlabel-elapsed">0</span> s
				<span id="parcselect-genlabel-retry-info" style="display:none;margin-left:6px;color:#b45309">
					(retry #<span id="parcselect-genlabel-retry-count">0</span>)
				</span>
			</div>
			<div id="parcselect-genlabel-emergency" style="display:none;margin-top:10px;padding:10px;
			     background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;font-size:11px;color:#92400e;line-height:1.5">
				⚠️ <?php esc_html_e( 'The label is delayed. If there is a problem, please contact', 'parcel-selector' ); ?> <a href="mailto:office@parcelselector.com" style="color:#b45309;font-weight:700;text-decoration:underline">office@parcelselector.com</a>.
			</div>
		</div>
		</div>

		<?php
		// Inline CSS extracted to assets/css/parcselect-label-embed.css for WP.org compliance.
		if ( ! wp_style_is( 'parcselect-label-embed', 'registered' ) ) {
			wp_register_style(
				'parcselect-label-embed',
				PARCSELECT_PLUGIN_URL . 'assets/css/parcselect-label-embed.css',
				array(),
				PARCSELECT_VERSION
			);
		}
		wp_enqueue_style( 'parcselect-label-embed' );
		?>

		<?php
		// v4.3.3 WP.org compliance: enqueue QRCode.js via wp_enqueue_script instead of hardcoded <script src>.
		// Handle is registered once even if metabox renders multiple times.
		if ( ! wp_script_is( 'parcselect-qrcode', 'enqueued' ) ) {
			wp_register_script( 'parcselect-qrcode', $qrcode_js, array(), defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '1.0.0', true );
			wp_enqueue_script( 'parcselect-qrcode' );
		}
		// NS-54 2026-04-30: PRELOAD PSLE_Confetti library so polling response can fire
		// fireworks + beep tones (C5 E5 G5) without `if (window.PSLE_Confetti)` silent skip.
		// Library is idempotent (static $done guard) — second call no-ops.
		if ( class_exists( 'Parcselect_Label_Stepper' ) ) {
			Parcselect_Label_Stepper::print_confetti_js();
		}
		if ( ! wp_script_is( 'parcselect-label-qr', 'registered' ) ) {
			wp_register_script(
				'parcselect-label-qr',
				PARCSELECT_PLUGIN_URL . 'assets/js/parcselect-label-qr.js',
				array( 'parcselect-qrcode' ),
				PARCSELECT_VERSION,
				true
			);
		}
		wp_enqueue_script( 'parcselect-label-qr' );
		wp_add_inline_script(
			'parcselect-label-qr',
			'window.PARCSELECT_LABEL_VARS = { orderId: ' . (int) $order_id . ', qrUri: ' . wp_json_encode( $qr_uri ) . ', expiresAt: ' . wp_json_encode( $expires_at ) . ', nonce: ' . wp_json_encode( $nonce ) . ' };',
			'before'
		);
	}

	/**
	 * v4.0.13 FAZA 2 — plugin-native Stage 4 UI (SOL payment wizard).
	 * Renders QR code + wallet + unique amount + "Paid" button.
	 * Replaces the VPS iframe for this stage entirely — zero chance of
	 * foreign tracking auto-render regardless of VPS state.
	 *
	 * @param WC_Order $order
	 */
	protected static function render_plugin_native_stage4( $order ) {
		$order_id    = $order->get_id();
		$size        = (string) $order->get_meta( '_ps_label_size', true );
		$price_pln   = (string) $order->get_meta( '_ps_label_price_pln', true );
		$amount_sol  = (string) $order->get_meta( '_ps_label_sol_amount', true );
		$wallet      = (string) $order->get_meta( '_ps_label_sol_wallet', true );
		$qr_uri      = (string) $order->get_meta( '_ps_label_qr_uri', true );
		$session_id  = (string) $order->get_meta( '_ps_label_session_id', true );
		$nonce       = wp_create_nonce( 'parcselect_label_consent' );
		$qrcode_js   = plugins_url( 'PS_LABEL/assets/js/qrcode.min.js', dirname( __DIR__ ) . '/parcel-selector.php' );
		?>
		<div id="parcselect-stage4" style="padding:24px;background:linear-gradient(135deg,#faf5ff 0%,#f3e8ff 100%);border:2px solid #a855f7;border-radius:12px;margin:10px 0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
			<?php // FIX #8 SELF_TX_WARNING (2026-05-15): operator ma seed do shared Oracle wallet,
			// Phantom Account 1 domyslnie wybiera ten wallet -> self-transfer = NET delta = -fee
			// -> Oracle ignoruje, srodki utkna. Warning banner Stage 4 ostrzeg przed self-TX. ?>
			<div style="background:#fef2f2;border:2px solid #fca5a5;border-radius:8px;padding:12px 16px;margin-bottom:16px">
				<strong style="color:#991b1b;font-size:13px;display:block;margin-bottom:4px">⚠ <?php esc_html_e( 'Do NOT pay from the wallet', 'parcel-selector' ); ?> <span style="font-family:monospace;font-size:10px;word-break:break-all;color:#7f1d1d;background:#fff;padding:2px 4px;border-radius:3px"><?php /* FIX B FULL_WALLET 2026-05-15: pelny adres zamiast skroconego */ echo esc_html( $wallet ); ?></span></strong>
				<p style="margin:6px 0 0;color:#7f1d1d;font-size:11px;line-height:1.5"><?php echo wp_kses( __( 'This is the system receiving wallet (shared). A self-transfer means the destination balance changes only by the fee → the Oracle will not record the payment. <strong>Send SOL from ANY OTHER wallet</strong> (Phantom Account 2, Solflare, Backpack, exchange withdrawal).', 'parcel-selector' ), array( 'strong' => array() ) ); ?></p>
			</div>
			<?php // FIX #8b WALLET_RECOMMEND (2026-05-15): klient musi użyć walleta
			// zachowującego pełną 9-decimal precision żeby Oracle SEC-4 (2026-04-24)
			// zmatchował dust ID. Exodus Desktop zaokrągla do 6 decimals -> mismatch. ?>
			<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px">
				<strong style="color:#166534;font-size:12px;display:block;margin-bottom:4px">✅ <?php esc_html_e( 'Recommended wallets (preserve 9 decimal places):', 'parcel-selector' ); ?></strong>
				<span style="color:#166534">Phantom (Chrome ext.), Solflare, Backpack</span>
				<strong style="color:#7f1d1d;font-size:12px;display:block;margin-top:6px">⚠️ <?php esc_html_e( 'NOT recommended:', 'parcel-selector' ); ?></strong>
				<span style="color:#7f1d1d"><?php esc_html_e( 'Exodus Desktop (rounds to 6 decimals → dust ID mismatch → partial status), Trust Wallet (incomplete Solana Pay URI support)', 'parcel-selector' ); ?></span>
			</div>
			<div style="text-align:center;margin-bottom:20px">
				<div style="display:inline-block;padding:4px 12px;background:#a855f7;color:#fff;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase">Solana (SOL)</div>
				<h3 style="margin:12px 0 4px;font-size:22px;color:#581c87"><?php esc_html_e( 'Scan the QR code to pay', 'parcel-selector' ); ?></h3>
				<p style="margin:0;color:#6b7280;font-size:13px"><?php esc_html_e( 'Wallet: Phantom, Solflare, Backpack, etc.', 'parcel-selector' ); ?></p>
			</div>
			<div style="display:grid;grid-template-columns:280px 1fr;gap:24px;align-items:center">
				<div>
					<div id="parcselect-qr-canvas" style="background:#fff;padding:16px;border-radius:12px;box-shadow:0 4px 16px rgba(168,85,247,0.15)"></div>
					<a href="<?php echo esc_url( $qr_uri ); ?>" style="display:block;margin-top:10px;text-align:center;padding:8px;background:#7c3aed;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;font-size:12px"><?php esc_html_e( 'Open in wallet →', 'parcel-selector' ); ?></a>
				</div>
				<div>
					<table style="width:100%;border-collapse:collapse;font-size:13px">
						<tr><td style="padding:6px 0;color:#6b7280"><?php esc_html_e( 'Size:', 'parcel-selector' ); ?></td><td style="padding:6px 0;font-weight:700;text-align:right"><?php echo esc_html( $size ); ?></td></tr>
						<tr><td style="padding:6px 0;color:#6b7280"><?php esc_html_e( 'Price:', 'parcel-selector' ); ?></td><td style="padding:6px 0;font-weight:700;text-align:right"><?php echo esc_html( $price_pln ); ?> PLN</td></tr>
						<tr><td style="padding:6px 0;color:#6b7280"><?php esc_html_e( 'SOL amount:', 'parcel-selector' ); ?></td><td style="padding:6px 0;font-weight:700;text-align:right;font-family:monospace;color:#7c3aed"><?php echo esc_html( $amount_sol ); ?></td></tr>
						<tr><td style="padding:6px 0;color:#6b7280"><?php esc_html_e( 'Wallet:', 'parcel-selector' ); ?></td><td style="padding:6px 0;font-family:monospace;font-size:10px;text-align:right;word-break:break-all"><?php echo esc_html( substr( $wallet, 0, 8 ) . '…' . substr( $wallet, -8 ) ); ?></td></tr>
						<tr><td style="padding:6px 0;color:#6b7280">Memo:</td><td style="padding:6px 0;font-family:monospace;font-size:10px;text-align:right"><?php echo esc_html( substr( (string) $session_id, 0, 12 ) . '…' ); ?></td></tr>
					</table>
					<div id="parcselect-payment-status" style="margin-top:16px;padding:12px;background:#fff;border-radius:8px;border:1px solid #e5e7eb;font-size:13px;color:#6b7280;text-align:center">
						<span id="parcselect-payment-spinner" style="display:inline-block;width:12px;height:12px;border:2px solid #a855f7;border-top-color:transparent;border-radius:50%;animation:parcsepa-spin 0.8s linear infinite;margin-right:6px;vertical-align:middle"></span>
						<span id="parcselect-payment-text"><?php esc_html_e( 'Waiting for payment confirmation on the blockchain…', 'parcel-selector' ); ?></span>
					</div>
					<?php wp_add_inline_style( 'wp-admin', '@keyframes parcsepa-spin{to{transform:rotate(360deg)}}' ); ?>
				</div>
			</div>
			<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb;text-align:center;font-size:11px;color:#9ca3af">
				Plugin-native Stage 4 (v4.0.13) &middot; <?php esc_html_e( 'Unique amount (dust lamports)', 'parcel-selector' ); ?> &middot; <?php esc_html_e( 'Polling every 10s', 'parcel-selector' ); ?>
			</div>
		</div>
		<?php
		// v4.3.3 WP.org compliance: enqueue QRCode.js via wp_enqueue_script instead of hardcoded <script src>.
		if ( ! wp_script_is( 'parcselect-qrcode', 'enqueued' ) ) {
			wp_register_script( 'parcselect-qrcode', $qrcode_js, array(), defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '1.0.0', true );
			wp_enqueue_script( 'parcselect-qrcode' );
		}
		?>
		<?php
		if ( ! wp_script_is( 'parcselect-label-pay', 'registered' ) ) {
			wp_register_script(
				'parcselect-label-pay',
				PARCSELECT_PLUGIN_URL . 'assets/js/parcselect-label-pay.js',
				array( 'parcselect-qrcode' ),
				PARCSELECT_VERSION,
				true
			);
		}
		wp_enqueue_script( 'parcselect-label-pay' );
		wp_add_inline_script(
			'parcselect-label-pay',
			'window.PARCSELECT_LABEL_PAY = { qrUri: ' . wp_json_encode( $qr_uri ) . ', nonce: ' . wp_json_encode( $nonce ) . ', orderId: ' . (int) $order_id . ' };',
			'before'
		);
	}

	/**
	 * v4.0.13 — AJAX: create unique payment session for Stage 4 (SOL payment).
	 *
	 * Called by bridge_js after iframe fires ps_label_size_selected.
	 * Generates a UNIQUE amount (base PLN price for selected size + random
	 * dust lamports 1-999) — VPS X21 daemon cannot match this session
	 * against any cached TX because the amount is unique to this session.
	 * Response contains QR payload + wallet + amount — plugin renders
	 * native Stage 4 UI locally, NOT via VPS iframe.
	 */
	public static function ajax_create_session() {
		// v4.2 SPEC/11 A.B1 — backend gate: KAZDY request musi miec aktualna zgode.
		// Bez tego polityka SPEC/11 sekcja 4 NIE jest egzekwowana.
		if ( class_exists( 'Parcselect_Label_Consent_Gate' ) ) {
			$consent_check = Parcselect_Label_Consent_Gate::require_consent_or_fail();
			if ( is_wp_error( $consent_check ) ) {
				wp_send_json_error( array(
					'message' => $consent_check->get_error_code(),
					'detail'  => $consent_check->get_error_message(),
					'gate'    => 'consent_required',
				), 403 );
				return;
			}
		}
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$size     = isset( $_POST['size'] ) ? strtoupper( sanitize_key( wp_unslash( $_POST['size'] ) ) ) : '';
		$weight   = isset( $_POST['weight'] ) ? (float) $_POST['weight'] : 1.0;

		if ( ! $order_id || ! in_array( $size, array( 'A', 'B', 'C' ), true ) ) {
			wp_send_json_error( array( 'message' => 'bad_input' ), 400 );
		}
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order_not_found' ), 404 );
		}

		// v4.1.8: BLOCK regeneration if order already has completed label.
		// Po wygenerowaniu etykiety w danym zamówieniu NIGDY więcej nie można jej
		// ponownie utworzyć ani opłacić — etykieta jest finalna.
		$_existing_status = (string) $order->get_meta( '_ps_label_status', true );
		$_existing_track  = (string) $order->get_meta( '_ps_label_tracking_number', true );
		if ( 'completed' === $_existing_status && '' !== $_existing_track ) {
			wp_send_json_error( array(
				'message' => 'order_already_completed',
				/* translators: %s = tracking number of the existing label */
				'detail'  => sprintf( __( 'The label for this order has already been generated (%s) — it cannot be regenerated.', 'parcel-selector' ), $_existing_track ),
			), 409 );
		}

		// FIX #6 IDEMPOTENCY (2026-05-15): jesli aktywny pi_id istnieje + jeszcze nie wygasl,
		// zwroc istniejacy zamiast tworzyc nowy. Zapobiega case #301: plugin retry tworzyl
		// 8 register_order w 45s. Bug Oracle (brak idempotency po stronie wyroczni) -
		// workaround tylko w pluginie (Oracle X21 nietkniete).
		$_existing_pi_id   = (string) $order->get_meta( '_ps_label_pi_id', true );
		$_existing_expires = (string) $order->get_meta( '_ps_label_expires_at', true );
		if ( '' !== $_existing_pi_id && '' !== $_existing_expires ) {
			$_expires_ts = strtotime( $_existing_expires );
			if ( $_expires_ts && $_expires_ts > time() && in_array( $_existing_status, array( 'awaiting_payment' ), true ) ) {
				$_existing_payload = $order->get_meta( '_ps_label_pscp_payload', true );
				if ( is_string( $_existing_payload ) && '' !== $_existing_payload ) {
					$_payload_decoded = json_decode( $_existing_payload, true );
					if ( is_array( $_payload_decoded ) ) {
						if ( defined( 'PARCSELECT_DEBUG' ) && PARCSELECT_DEBUG && function_exists( 'error_log' ) ) {
							error_log( 'PSLE/CEB: register_order REUSE pi=' . substr( $_existing_pi_id, 0, 12 ) . ' (idempotency) order #' . $order_id ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie diagnostyczne lancucha CEB, gated PARCSELECT_DEBUG.
						}
						wp_send_json_success( array_merge( $_payload_decoded, array( 'pi_id' => $_existing_pi_id, 'reused' => true ) ) );
						return;
					}
				}
			}
		}

		// B1+B2 (v4.1): delegate EVERYTHING to Oracle.
		// Oracle computes dust-ID + calls ps-cryptopay /build-intent for the full
		// payment-intent payload (wallet, 3-source averaged rate, QR URI, i18n,
		// stepper labels, wallet warning text). Plugin client contains no math,
		// no hardcoded prices, no rate fetching — just forwards the request
		// (HMAC signed) and stores the signed payload returned by Oracle.

		// BUG #12 (2026-05-15) — fallback receiver-phone validator.
		// Marker: invalid_receiver_phone. Furgonetka rejects /receiver/phone
		// unless exactly 9 digits (after optional +48 prefix). Hard-fail here
		// BEFORE calling Oracle to give the admin a clear actionable error
		// (instead of cryptic Furgonetka 422 deep in pipeline).
		$billing_phone_digits = preg_replace( '/\D/', '', (string) $order->get_billing_phone() );
		if ( strlen( $billing_phone_digits ) === 11 && substr( $billing_phone_digits, 0, 2 ) === '48' ) {
			$billing_phone_digits = substr( $billing_phone_digits, 2 );
		}
		if ( strlen( $billing_phone_digits ) !== 9 ) {
			wp_send_json_error( array(
				'message' => 'invalid_receiver_phone',
				/* translators: 1: actual digit count, 2: order ID */
				'detail'  => sprintf( __( 'The recipient phone number (billing_phone) has %1$d digits. The carrier requires exactly 9. Edit the order: WP Admin -> Orders -> %2$d -> Billing details -> Phone.', 'parcel-selector' ), strlen( $billing_phone_digits ), $order->get_id() ),
			), 422 );
		}

		if ( ! class_exists( 'Parcselect_Oracle_Client' ) ) {
			wp_send_json_error( array( 'message' => 'oracle_client_missing' ), 500 );
		}

		// Ensure plugin is enrolled. On first run, call /api/v1/enroll.
		if ( ! Parcselect_Oracle_Client::is_enrolled() ) {
			$enroll = Parcselect_Oracle_Client::enroll();
			if ( is_wp_error( $enroll ) ) {
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::error( $order_id, 'oracle.enroll_failed', array(
						'code'    => $enroll->get_error_code(),
						'message' => $enroll->get_error_message(),
					) );
				}
				wp_send_json_error( array(
					'message' => 'enroll_failed',
					'detail'  => $enroll->get_error_message(),
				), 502 );
			}
		}

		// Pricing: map size → fiat amount. These values are authoritative on the
		// Oracle/ps-cryptopay side — plugin forwards them as a hint, Oracle
		// re-computes per its own config. Keeping them here for UI estimate only.
		$base_pln  = parcselect_label_cennik(); // CENNIK-FETCH 2026-05-16: cennik z VPS (endpoint CEB); cene platnosci i tak egzekwuje CEB
		$price_pln = (float) $base_pln[ $size ];

		// Persistent session_id per order (used as wc_order_key hint for Oracle
		// idempotency; real pi_id comes from Oracle as UUID).
		$session_id = (string) $order->get_meta( '_ps_label_session_id', true );
		if ( '' === $session_id ) {
			$session_id = function_exists( 'wp_generate_uuid4' )
				? wp_generate_uuid4()
				: bin2hex( random_bytes( 16 ) );
			$order->update_meta_data( '_ps_label_session_id', $session_id );
			$order->save();
		}

		$wc_order_key = (string) $order->get_order_key();
		if ( '' === $wc_order_key ) {
			// Fallback: synthesize a key if WC didn't generate one (HPOS edge case)
			$wc_order_key = 'wc_' . substr( md5( home_url() . '|' . $order_id ), 0, 20 );
		}

		$locale = determine_locale();

		$oracle_payload = array(
			'wc_order_id'   => (string) $order_id,
			'wc_order_key'  => $wc_order_key,
			'coin'          => 'sol',
			'purpose'       => 'embed_label',
			'fiat_total'    => number_format( $price_pln, 2, '.', '' ),
			'currency'      => 'PLN',
			'ttl_minutes'   => 30,
			'locale'        => $locale ?: 'pl_PL',
			// address field is required by Oracle, but Oracle overwrites it with
			// wallet from /build-intent (ps-cryptopay settings). Placeholder OK.
			'address'       => (string) get_option( 'parcselect_label_wallet_sol', 'ApxCm7vshRs8bbexuZVEfYveLtPaLwVse6ngmthbxH6Q' ),
			// Oracle validates amount>0; build-intent overwrites this with
			// computed SOL value (base + dust). Passing fiat_total as placeholder.
			'amount'        => number_format( $price_pln, 2, '.', '' ),
			// Extra context for build-intent (plugin consent/sender/locker data
			// — not strictly needed by v4.1 but harmless and useful for A3 dispatch)
			'size'          => $size,
			'weight'        => (float) $weight,
			'source_site'   => home_url(),
			'source_order'  => (string) $order_id,
		);

		// ─── A4 (2026-04-25): dispatch_context for Oracle→CEB dispatcher ───
		// After admin pays on-chain, Oracle bg_validator detects TX and POSTs
		// to CEB /create-order-from-oracle with these fields so that CEB can
		// create a full WC order on etykietownik and trigger Furgonetka.
		$_locker_id      = (string) $order->get_meta( '_ps_locker_id', true );
		$_locker_name    = (string) $order->get_meta( '_ps_locker_name', true );
		$_locker_address = (string) ( $order->get_meta( '_ps_locker_address1', true ) ?: $order->get_meta( '_ps_locker_address', true ) );
		$_locker_city    = (string) $order->get_meta( '_ps_locker_city', true );
		// N8 KEY_NAMING_FIX 2026-05-15: fallback dla naming mismatch (canonical _ps_locker_post_code, legacy _ps_locker_postcode)
		$_locker_postcode = (string) $order->get_meta( '_ps_locker_post_code', true );
		if ( '' === $_locker_postcode ) {
			$_locker_postcode = (string) $order->get_meta( '_ps_locker_postcode', true );
		}
		$_carrier        = (string) ( $order->get_meta( '_ps_locker_carrier', true ) ?: $order->get_meta( '_ps_carrier', true ) );
		if ( empty( $_locker_id ) && ! empty( $_locker_name ) && preg_match( '/^[A-Z0-9\-]{3,30}$/i', $_locker_name ) ) {
			$_locker_id = $_locker_name;
		}
		if ( empty( $_carrier ) ) {
			$_carrier = 'inpost'; // FREE = InPost Paczkomaty only.
		}
		$_recv_phone = method_exists( $order, 'get_shipping_phone' ) ? $order->get_shipping_phone() : '';
		if ( empty( $_recv_phone ) ) { $_recv_phone = $order->get_billing_phone(); }
		$_recv_email = method_exists( $order, 'get_shipping_email' ) ? $order->get_shipping_email() : '';
		if ( empty( $_recv_email ) ) { $_recv_email = $order->get_billing_email(); }
		$_recv_first = $order->get_shipping_first_name() ?: $order->get_billing_first_name();
		$_recv_last  = $order->get_shipping_last_name()  ?: $order->get_billing_last_name();

		// NS-55 2026-04-30: Furgonetka /receiver/city wymaga `notAlpha` regex —
		// tylko litery + polskie znaki + spacja + myślnik. Mapa parcel-selector
		// zapisuje NAZWĘ paczkomatu (np. "Lidl ul. Warszawska 20 A") do
		// shipping_city — to łamie walidację. Zbuduj recv_city w priorytecie:
		//   1) wyciągnij miasto z _ps_locker_address1 (format "ul. X, KOD MIASTO")
		//   2) jeśli get_shipping_city() jest czyste-alpha → użyj go
		//   3) fallback na billing_city (zwykle czyste — wpisane przez klienta)
		$_recv_city_raw = (string) ( $order->get_shipping_city() ?: $order->get_billing_city() );
		$_locker_addr   = (string) ( $order->get_meta( '_ps_locker_address1', true ) ?: $order->get_meta( '_ps_locker_address', true ) );
		$_recv_city     = '';
		// 1. Try locker address1 — typical format "ul. Warszawska 20A, 21-400 Łuków"
		if ( '' !== $_locker_addr && preg_match( '/\d{2}-\d{3}\s+([\p{L}\s\-]+?)$/u', $_locker_addr, $m ) ) {
			$_recv_city = trim( $m[1] );
		}
		// 2. Validate raw city (alpha + spaces + dashes only)
		if ( '' === $_recv_city && '' !== $_recv_city_raw && preg_match( '/^[\p{L}\s\-]+$/u', $_recv_city_raw ) ) {
			$_recv_city = $_recv_city_raw;
		}
		// 3. Fallback to billing_city
		if ( '' === $_recv_city ) {
			$_billing_city = (string) $order->get_billing_city();
			if ( '' !== $_billing_city && preg_match( '/^[\p{L}\s\-]+$/u', $_billing_city ) ) {
				$_recv_city = $_billing_city;
			}
		}
		// 4. Last resort — strip non-alpha from raw (best-effort)
		if ( '' === $_recv_city && '' !== $_recv_city_raw ) {
			$_recv_city = trim( preg_replace( '/[^\p{L}\s\-]/u', '', $_recv_city_raw ) );
		}

		$_recv_post  = $order->get_shipping_postcode()   ?: $order->get_billing_postcode();
		$_recv_str   = trim( (string) ( $order->get_shipping_address_1() ?: $order->get_billing_address_1() ) );

		// ─── Bug #13/#14/#15/#16 FIX (2026-05-15): locker mapping reklasyfikacja ───
		// Problem (order #424 audit):
		//   recv_postcode  = "02-495"  (sender postcode bo plugin nie nadpisal billing pustym ⇒ skopiowal sender)
		//   recv_street    = "Partyzantów 3A, 21-450 Stoczek Łukowski"  (pełen adres locker zamiast samej ulicy)
		//   locker_postcode = ""        (meta _ps_locker_postcode puste — paczkomatomapy nie zapisuje)
		//   locker_city     = "Przy wejściu"  (meta _ps_locker_city = OPIS LOKALU, nie miasto!)
		// Rozwiązanie: parse_locker_address( _ps_locker_address1 ) → street + postcode + city.
		// Override tylko gdy wartość docelowa pusta lub błędna. Idempotent.
		$locker_parsed_b13   = self::parse_locker_address( (string) ( $order->get_meta( '_ps_locker_address1', true ) ?: $order->get_meta( '_ps_locker_address', true ) ) );
		$_locker_city_meta   = (string) $order->get_meta( '_ps_locker_city', true );
		// Heurystyka: meta _ps_locker_city to typowo "Przy wejściu", "Pawilon X", itp.
		// Jeśli parsed city istnieje — preferuj parsed; meta tylko jako fallback.
		$_locker_city_fixed     = ! empty( $locker_parsed_b13['city'] ) ? $locker_parsed_b13['city'] : $_locker_city_meta;
		$_locker_postcode_fixed = ! empty( $locker_parsed_b13['postcode'] ) ? $locker_parsed_b13['postcode'] : $_locker_postcode;
		$_locker_street_only    = (string) $locker_parsed_b13['street'];

		// recv_street: jeśli klient ma wpisany billing/shipping address_1 — użyj go;
		// w przeciwnym razie sama ulica z parsed locker (bez "21-450 Stoczek...").
		$_billing_addr1 = trim( (string) ( $order->get_shipping_address_1() ?: $order->get_billing_address_1() ) );
		if ( '' !== $_billing_addr1 ) {
			$_recv_str = $_billing_addr1;
		} elseif ( '' !== $_locker_street_only ) {
			$_recv_str = $_locker_street_only;
		}
		// recv_postcode: priorytet billing_postcode → locker_postcode parsed → sender (skipped).
		// Jeśli obecne $_recv_post jest wartością sender — preferuj parsed locker.
		$_billing_post = (string) $order->get_billing_postcode();
		if ( '' !== $_billing_post ) {
			$_recv_post = $_billing_post;
		} elseif ( ! empty( $locker_parsed_b13['postcode'] ) ) {
			$_recv_post = $locker_parsed_b13['postcode'];
		}
		// recv_city: jeśli billing_city alpha-only — użyj; inaczej parsed locker city; inaczej już-policzone $_recv_city.
		$_billing_city = (string) $order->get_billing_city();
		if ( '' !== $_billing_city && preg_match( '/^[\p{L}\s\-]+$/u', $_billing_city ) ) {
			$_recv_city = $_billing_city;
		} elseif ( ! empty( $locker_parsed_b13['city'] ) ) {
			$_recv_city = $locker_parsed_b13['city'];
		}

		$dispatch_context = array(
			'size'            => $size,
			'weight'          => (float) $weight,
			'carrier'         => $_carrier,
			'locker_id'       => $_locker_id,
			'locker_name'     => $_locker_name,
			'locker_address'  => $_locker_address,
			'locker_city'     => $_locker_city_fixed,       // Bug #16 FIX
			'locker_postcode' => $_locker_postcode_fixed,   // Bug #15 FIX
			'recv_name'       => trim( $_recv_first . ' ' . $_recv_last ),
			'recv_phone'      => $_recv_phone,
			'recv_email'      => $_recv_email,
			'recv_city'       => $_recv_city,
			'recv_postcode'   => $_recv_post,               // Bug #13 FIX (preferuje billing nad sender)
			'recv_street'     => $_recv_str,                // Bug #14 FIX (ulica, nie pełen adres locker)
			'recv_company'    => (string) ( $order->get_shipping_company() ?: $order->get_billing_company() ),
			// NS-62 2026-04-30: Furgonetka /pickup/name wymaga ≥2 slow (notFull regex).
			// Sklepy ktore wpisuja nazwe firmy ("Carbom") jako sender_name failed z notFull.
			// Fix: jesli jednoslowne → dorzuc " Shop" zeby przeszlo walidacje (firma jako
			// drugie slowo). Backwards-compat: 2+ slow zostaja bez zmian.
			'snd_name'        => ( function() {
				$_n = (string) get_option( 'parcselect_label_sender_name', get_option( 'blogname', '' ) );
				$_n = trim( $_n );
				if ( '' !== $_n && false === strpos( $_n, ' ' ) ) {
					$_n = $_n . ' Shop'; // 1-word → "<name> Shop" (Furgonetka notFull pass)
				}
				return $_n;
			} )(),
			'snd_company'     => (string) get_option( 'parcselect_label_sender_company',  '' ),
			'snd_street'      => (string) get_option( 'parcselect_label_sender_street',   trim( get_option( 'woocommerce_store_address', '' ) . ' ' . get_option( 'woocommerce_store_address_2', '' ) ) ),
			'snd_city'        => (string) get_option( 'parcselect_label_sender_city',     get_option( 'woocommerce_store_city', '' ) ),
			'snd_postcode'    => (string) get_option( 'parcselect_label_sender_postcode', get_option( 'woocommerce_store_postcode', '' ) ),
			'snd_phone'       => (string) get_option( 'parcselect_label_sender_phone',    '' ),
			'snd_email'       => (string) get_option( 'parcselect_label_sender_email',    get_option( 'admin_email' ) ),
			// NS-59 2026-04-30: paczkomat nadawczy (opcjonalny). Gdy ustawiony — CEB
			// na OVH wyśle do Furgonetka jako pickup.pickup_point_code zamiast adresu
			// → tańsza etykieta (~6 PLN paczkomat-to-paczkomat vs ~13 PLN kurier pickup).
			'snd_locker_id'   => (string) ( get_option( 'parcselect_sender_locker_id', '' ) ?: get_option( 'parcselect_label_sender_locker_id', '' ) ),
			'ceb_salt'        => ( function() {
				$s = (string) get_option( 'parcselect_label_ceb_salt', '' );
				if ( '' === $s ) {
					$s = function_exists( 'wp_generate_password' ) ? wp_generate_password( 48, false, false ) : bin2hex( random_bytes( 24 ) );
					update_option( 'parcselect_label_ceb_salt', $s, false );
				}
				return $s;
			} )(),
		);
		$oracle_payload['dispatch_context'] = $dispatch_context;

		// v4.4.5 audyt #7 — logowanie diagnostyczne lancucha CEB tylko przy
		// wlaczonym trybie debug. Wczesniej error_log() wykonywal sie BEZWARUNKOWO
		// przy kazdym register_order (zaśmiecanie logow produkcji + treść payloadu).
		if ( defined( 'PARCSELECT_DEBUG' ) && PARCSELECT_DEBUG && function_exists( 'error_log' ) ) {
			error_log( 'PSLE/CEB: register_order DISPATCH for order #' . $order_id . ' / pi=' . substr( (string) ( $oracle_payload['pi_id'] ?? '' ), 0, 12 ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie diagnostyczne lancucha CEB, gated PARCSELECT_DEBUG.
		}
		$intent = Parcselect_Oracle_Client::register_order( $oracle_payload );
		if ( defined( 'PARCSELECT_DEBUG' ) && PARCSELECT_DEBUG && function_exists( 'error_log' ) ) {
			if ( is_wp_error( $intent ) ) {
				error_log( 'PSLE/CEB: register_order FAIL order #' . $order_id . ' code=' . $intent->get_error_code() . ' msg=' . substr( $intent->get_error_message(), 0, 200 ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie diagnostyczne lancucha CEB, gated PARCSELECT_DEBUG.
			} else {
				error_log( 'PSLE/CEB: register_order OK order #' . $order_id . ' resp=' . substr( wp_json_encode( $intent ), 0, 200 ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie diagnostyczne lancucha CEB, gated PARCSELECT_DEBUG.
			}
		}

		if ( is_wp_error( $intent ) ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order_id, 'oracle.register_failed', array(
					'code'    => $intent->get_error_code(),
					'message' => $intent->get_error_message(),
				) );
			}
			wp_send_json_error( array(
				'message' => 'oracle_register_failed',
				'detail'  => $intent->get_error_message(),
			), 502 );
		}

		// Extract authoritative values from Oracle-signed payload
		$pi_id        = (string) ( $intent['pi_id'] ?? '' );

		// v4.2 SPEC/11 A.B2 — per-order audit snapshot dla disputes
		if ( class_exists( 'Parcselect_Label_Consent_Gate' ) && '' !== $pi_id ) {
			Parcselect_Label_Consent_Gate::record_per_order_use( $order_id, $pi_id );
		}

		// v4.2 fix_v4115 — pin pi_id w intent przed save (eliminuje 'payload pi_id mismatch')
		// Bez tego: jesli build-intent zwroci payload bez pi_id albo z innym, plugin render
		// zglosi blad. Pin gwarantuje payload[pi_id] === meta._ps_label_pi_id.
		if ( '' !== $pi_id && is_array( $intent ) ) {
			$intent['pi_id'] = $pi_id;
		}
		$wallet       = (string) ( $intent['wallet_address'] ?? '' );
		$amount_sol_s = (string) ( $intent['amount'] ?? '' );
		$amount_base  = (string) ( $intent['amount_base'] ?? $amount_sol_s );
		$dust_id      = (int)    ( $intent['dust_id'] ?? 0 );
		$qr_uri       = (string) ( $intent['qr_uri'] ?? '' );
		$memo         = (string) ( $intent['memo'] ?? ( 'pi:' . $pi_id ) );
		$expires_at   = (string) ( $intent['expires_at'] ?? '' );

		// Cache full payload as JSON meta (single key — plugin render reads from here).
		// This is the ONE source-of-truth for Krok 2 UI. Signature verified by
		// Parcselect_Oracle_Client::register_order() before this line is reached.
		$order->update_meta_data( '_ps_label_pscp_payload', wp_json_encode(
			$intent,
			JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
		) );

		// Indexed scalars for fast query / legacy code compat.
				$order->update_meta_data( '_ps_label_pi_id',       $pi_id );
		// NS-74 2026-04-30: ATOMIC stamp installation_id RAZEM z pi_id.
		// Bez tego: reset _ps_label_installation_id (cache flush, custom migration,
		// admin wp-cli eval) → handle_callback HMAC validation 403 → label utracona.
		// Self-heal: jeśli global option puste, użyj per-order stamp jako fallback.
		$order->update_meta_data( '_ps_label_installation_id', (string) get_option( 'parcselect_installation_id', '' ) );
		$order->update_meta_data( '_ps_label_size',        $size );
		$order->update_meta_data( '_ps_label_weight',      $weight );
		$order->update_meta_data( '_ps_label_price_pln',   $price_pln );
		$order->update_meta_data( '_ps_label_sol_amount',  $amount_sol_s );
		$order->update_meta_data( '_ps_label_sol_amount_base', $amount_base );
		$order->update_meta_data( '_ps_label_dust_id',     $dust_id );
		$order->update_meta_data( '_ps_label_sol_wallet',  $wallet );
		$order->update_meta_data( '_ps_label_qr_uri',      $qr_uri );
		$order->update_meta_data( '_ps_label_memo',        $memo );
		$order->update_meta_data( '_ps_label_expires_at',  $expires_at );
		$order->update_meta_data( '_ps_label_status',      'awaiting_payment' );
		$order->update_meta_data( '_ps_label_created_at',  current_time( 'mysql' ) );
		// Oracle rate info for UI fallback
		$order->update_meta_data( '_ps_label_sol_rate',    (string) ( $intent['rate'] ?? '' ) );
		$order->save();

		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'oracle.session.created', array(
				'pi_id'      => substr( $pi_id, 0, 8 ) . '…',
				'size'       => $size,
				'price_pln'  => $price_pln,
				'amount_sol' => $amount_sol_s,
				'dust_id'    => $dust_id,
				'rate'       => $intent['rate'] ?? null,
				'sources'    => $intent['rate_sources_count'] ?? 0,
			) );
		}

		wp_send_json_success( array(
			'pi_id'      => $pi_id,
			'size'       => $size,
			'amount_sol' => $amount_sol_s,
			'wallet'     => $wallet,
			'reload'     => true,
		) );
	}

	/**
	 * v4.0.13 — AJAX: poll webhook status. Called every 10s by Stage 4 JS
	 * to detect when VPS webhook confirms the payment. Short-circuits when
	 * local meta already says 'completed' and safe_to_render() passes.
	 */
	/**
	 * NS-54 v3 — force Oracle re-verify (escalation cascade).
	 * Called by polling JS at 60s/120s after paid detection if label_url
	 * still missing. Hits /api/v1/orders/<pi_id>/check-now which forces
	 * Oracle to re-verify on-chain + ideally re-trigger downstream
	 * CEB/Furgonetka pipeline. Idempotent: rate-limited 30s per order.
	 */
	public static function ajax_force_recheck() {
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$order    = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order_not_found' ), 404 );
		}
		// Rate limit: max 1 force-recheck per 30s per order.
		$rl_key = 'parcselect_recheck_' . $order_id;
		if ( get_transient( $rl_key ) ) {
			wp_send_json_success( array( 'skipped' => 'rate_limited' ) );
		}
		set_transient( $rl_key, 1, 30 );

		$pi_id = (string) $order->get_meta( '_ps_label_pi_id', true );
		if ( '' === $pi_id || ! class_exists( 'Parcselect_Oracle_Client' ) ) {
			wp_send_json_error( array( 'message' => 'no_pi_or_client' ), 400 );
		}
		$result = Parcselect_Oracle_Client::check_now( $pi_id );
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'force_recheck.fired', array(
				'pi_id'  => substr( $pi_id, 0, 12 ),
				'result' => is_wp_error( $result ) ? $result->get_error_code() : ( is_array( $result ) ? ( $result['status'] ?? '?' ) : '?' ),
			) );
		}
		wp_send_json_success( array(
			'pi_id'  => substr( $pi_id, 0, 12 ) . '…',
			'result' => is_wp_error( $result ) ? array( 'error' => $result->get_error_message() ) : $result,
		) );
	}

	/**
	 * NS-63 — walidacja paczkomatu nadawczego w bazie InPost API.
	 * NS-86 2026-04-30: ajax_validate_locker() REMOVED — eliminated 4-th party
	 * api-shipx-pl.easypack24.net call. Klient odpowiedzialny za poprawność
	 * sender_locker_id wpisanego w admin form (regex client-side validation).
	 */

	/**
	 * NS-75 — manual recovery: admin klik "Wyciągnij etykietę z Oracle" gdy webhook nie dotarł.
	 * Plugin queries Oracle status — jeśli `paid`, próbuje uzupełnić label_url + tracking.
	 * Multi-layer defense — gwarancja "NIC NIE MOŻE HAMOWAĆ PŁACENIA".
	 */
	public static function ajax_recover_label() {
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$order    = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order_not_found' ), 404 );
		}
		$pi_id = (string) $order->get_meta( '_ps_label_pi_id', true );
		if ( '' === $pi_id ) {
			wp_send_json_error( array(
				'message' => 'no_pi_id',
				'detail'  => __( 'No _ps_label_pi_id in the order meta — cannot retrieve from the Oracle. Please contact office@parcelselector.com.', 'parcel-selector' ),
			), 400 );
		}
		if ( ! class_exists( 'Parcselect_Oracle_Client' ) ) {
			wp_send_json_error( array( 'message' => 'no_oracle_client' ), 500 );
		}
		// 1. Force re-check Oracle (in case bg_validator missed it)
		$check = Parcselect_Oracle_Client::check_now( $pi_id );
		// 2. Get full status
		$os = Parcselect_Oracle_Client::get_status( $pi_id );
		if ( is_wp_error( $os ) ) {
			wp_send_json_error( array( 'message' => 'oracle_error', 'detail' => $os->get_error_message() ), 502 );
		}
		$status = (string) ( $os['status'] ?? 'unknown' );
		// 3. Self-populate any fields Oracle returned (txid, paid_at)
		if ( ! empty( $os['txid'] ) && '' === (string) $order->get_meta( '_ps_label_txid', true ) ) {
			$order->update_meta_data( '_ps_label_txid', (string) $os['txid'] );
		}
		if ( ! empty( $os['confirmed_at'] ) && '' === (string) $order->get_meta( '_ps_label_paid_at', true ) ) {
			$order->update_meta_data( '_ps_label_paid_at', (string) $os['confirmed_at'] );
		}
		if ( 'paid' === $status && 'awaiting_payment' === (string) $order->get_meta( '_ps_label_status', true ) ) {
			$order->update_meta_data( '_ps_label_status', 'paid' );
		}
		$order->save();
		// 4. If Oracle has label_url/tracking in payload (NS-76 future enhancement on Oracle side)
		//    populate them — currently Oracle doesn't expose CEB label_url, so admin
		//    needs to wait for retry webhook OR contact support.
		$reply = array(
			'oracle_status' => $status,
			'oracle_txid'   => isset( $os['txid'] ) ? substr( (string) $os['txid'], 0, 16 ) . '…' : '',
			'message'       => 'paid' === $status
				? __( 'Payment confirmed. The label may take a moment — refresh the page in 60 seconds or contact office@parcelselector.com if it is still missing.', 'parcel-selector' )
				: ( 'pending' === $status ? __( 'Payment is pending on-chain. Try again in 30-60 seconds.', 'parcel-selector' ) : sprintf( /* translators: %s = payment status */ __( 'Status: %s', 'parcel-selector' ), esc_html( $status ) ) ),
		);
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'recover_label.fired', array(
				'pi_id'  => substr( $pi_id, 0, 12 ),
				'status' => $status,
			) );
		}
		wp_send_json_success( $reply );
	}

	/**
	 * Wstrzykuje SWIEZY nonce do URL-a etykiety (endpoint parcselect_label_pdf).
	 * Nonce NIE jest trzymany w meta `_ps_label_url` — wygasa po 12-24h i uczynilby
	 * etykiete "martwa". URL-e spoza naszego endpointu zwraca bez zmian.
	 *
	 * @param string $url
	 * @return string
	 */
	private static function fresh_label_url( $url ) {
		$url = (string) $url;
		if ( '' !== $url && class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			return Parcselect_Label_Pdf_Store::ensure_fresh_nonce( $url );
		}
		return $url;
	}

	/**
	 * NS-79 — PDF proxy download. Fetches label PDF server-side from _ps_label_url
	 * and returns z Content-Disposition: attachment header. Eliminuje cross-origin
	 * issue z <a download> (Chrome ignoruje attribute dla 3rd-party hosts).
	 */
	public static function ajax_download_pdf_proxy() {
		$order_id = isset( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
		$nonce    = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'parcselect_label_download_' . $order_id ) ) {
			wp_die( 'invalid_nonce', 403, array( 'response' => 403 ) );
		}
		if ( ! current_user_can( 'edit_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
			wp_die( 'forbidden', 403, array( 'response' => 403 ) );
		}
		$order = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_die( 'order_not_found', 404, array( 'response' => 404 ) );
		}
		// M3: jesli PDF jest w tabeli DB klienta — serwuj WPROST z DB. Etykieta
		// zyje w WordPressie klienta bezterminowo. (wp_remote_get na wlasny
		// endpoint parcselect_label_pdf i tak by sie nie zautoryzowal — zadanie
		// server-side nie niesie ciastek zalogowanego admina.)
		if ( class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			$_db_row = Parcselect_Label_Pdf_Store::get_db_pdf( $order_id );
			if ( is_array( $_db_row ) && isset( $_db_row['pdf_blob'] )
				&& strlen( (string) $_db_row['pdf_blob'] ) >= 1000 ) {
				$_db_body = (string) $_db_row['pdf_blob'];
				$_db_trk  = (string) $order->get_meta( '_ps_label_tracking_number', true );
				$_db_fn   = 'etykieta-' . $order_id . ( $_db_trk ? '-' . substr( $_db_trk, -8 ) : '' ) . '.pdf';
				nocache_headers();
				header( 'Content-Type: application/pdf' );
				header( 'Content-Disposition: attachment; filename="' . $_db_fn . '"' );
				header( 'Content-Length: ' . strlen( $_db_body ) );
				header( 'X-Content-Type-Options: nosniff' );
				echo $_db_body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary PDF body
				exit;
			}
		}
		$url = (string) $order->get_meta( '_ps_label_url', true );
		if ( '' === $url ) {
			wp_die( 'no_label_url', 404, array( 'response' => 404 ) );
		}
		// Heal stary/przeniesiony nonce zanim sprobujemy pobrac (URL-e zdalne bez zmian).
		$url  = self::fresh_label_url( $url );
		$resp = wp_remote_get( $url, array( 'timeout' => 15, 'sslverify' => true ) );
		if ( is_wp_error( $resp ) || 200 !== wp_remote_retrieve_response_code( $resp ) ) {
			wp_die( esc_html( is_wp_error( $resp ) ? $resp->get_error_message() : 'fetch_failed_http_' . wp_remote_retrieve_response_code( $resp ) ), 502, array( 'response' => 502 ) );
		}
		$body = wp_remote_retrieve_body( $resp );
		$tracking = (string) $order->get_meta( '_ps_label_tracking_number', true );
		$filename = 'etykieta-' . $order_id . ( $tracking ? '-' . substr( $tracking, -8 ) : '' ) . '.pdf';
		nocache_headers();
		header( 'Content-Type: application/pdf' );
		header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
		header( 'Content-Length: ' . strlen( $body ) );
		header( 'X-Content-Type-Options: nosniff' );
		echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary PDF body
		exit;
	}

	public static function ajax_check_payment() {
		check_ajax_referer( 'parcselect_label_consent', 'nonce' );
		// NS-52 fix 2026-04-30: cap check tolerant. Wczesniej 'edit_shop_orders' blokowal
		// admin/shop_manager (OK dla nich) + każdego innego (forbidden 403).
		// Plus polling z wp-admin frontend metabox JEST w admin context (zalogowany user).
		// Plus log dla debug gdy 403 (zeby diagnozowac konkretny capability problem).
		if ( ! current_user_can( 'edit_shop_orders' ) && ! current_user_can( 'manage_woocommerce' ) ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::warn( null, 'ajax_check_payment.forbidden', array(
					'user_id'   => get_current_user_id(),
					'caps'      => array_keys( wp_get_current_user()->allcaps ?? array() ),
				) );
			}
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
		$order    = $order_id ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			wp_send_json_error( array( 'message' => 'order_not_found' ), 404 );
		}
		$status   = (string) $order->get_meta( '_ps_label_status', true );
		$tracking = (string) $order->get_meta( '_ps_label_tracking_number', true );
		$safe     = class_exists( 'Parcselect_Label_Installation' ) && Parcselect_Label_Installation::safe_to_render( $order );

		// B3 (2026-04-25): poll Oracle /status while awaiting_payment.
		// Oracle bg_validator detects on-chain TX (60s) → status='paid'.
		// Webhook from CEB writes tracking → status='completed'.
		// Between those two states admin sees 'paid' badge.
		$pi_id         = (string) $order->get_meta( '_ps_label_pi_id', true );
		$oracle_status = null;
		$oracle_txid   = null;
		if ( '' !== $pi_id && 'completed' !== $status && class_exists( 'Parcselect_Oracle_Client' ) ) {
			$os = Parcselect_Oracle_Client::get_status( $pi_id );
			if ( ! is_wp_error( $os ) && is_array( $os ) ) {
				$oracle_status = isset( $os['status'] ) ? (string) $os['status'] : '';
				$oracle_txid   = isset( $os['txid'] )   ? (string) $os['txid']   : '';
				if ( 'paid' === $oracle_status && 'awaiting_payment' === $status ) {
					$order->update_meta_data( '_ps_label_status', 'paid' );
					if ( '' !== $oracle_txid ) {
						$order->update_meta_data( '_ps_label_txid', $oracle_txid );
					}
					if ( isset( $os['confirmed_at'] ) ) {
						$order->update_meta_data( '_ps_label_paid_at', (string) $os['confirmed_at'] );
					}
					$order->save();
					$status = 'paid';
					if ( class_exists( 'Parcselect_Label_Debug' ) ) {
						Parcselect_Label_Debug::info( $order_id, 'oracle.paid_detected', array(
							'pi_id' => $pi_id, 'txid' => substr( $oracle_txid, 0, 16 ),
						) );
					}
				}
			}
		}

		// NS-54 2026-04-30: expose label_url so polling JS can auto-open PDF
		// in a new tab the moment etykietownik webhook lands tracking_number.
		// Only when safe (PS_Label_Installation guard) — never leak across installations.
		$label_url = $safe ? self::fresh_label_url( $order->get_meta( '_ps_label_url', true ) ) : '';
		// NS-56/57: expose label generation failure so polling JS can render error
		// panel z instrukcją refundu. NS-57: NIGDY nie ujawniaj nazw dostawców
		// (Furgonetka, InPost, CEB, VPS, Oracle) klientowi — strip + generic message.
		$error_msg_raw = $safe ? (string) $order->get_meta( '_ps_label_error', true ) : '';
		$error_msg = '';
		if ( '' !== $error_msg_raw ) {
			$error_msg = preg_replace(
				'/\b(Furgonetka|InPost|CEB|VPS|etykietownik|integrator|api\.parcelselector|crypto-etyk-bridge|Oracle|ps-cryptopay|location_description|address_details)\b[^.]*\.?/i',
				'',
				$error_msg_raw
			);
			$error_msg = trim( preg_replace( '/\s{2,}/', ' ', $error_msg ) );
			if ( '' === $error_msg || strlen( $error_msg ) < 8 ) {
				$error_msg = __( 'A technical error occurred while generating the label.', 'parcel-selector' );
			}
		}
		wp_send_json_success( array(
			'status'        => $status,
			'safe'          => $safe,
			'tracking'      => $safe ? $tracking : '',
			'label_url'     => $label_url,
			'error'         => ( 'error' === $status || '' !== $error_msg ),
			'error_msg'     => $error_msg,
			'pi_id'         => $pi_id,
			'oracle_status' => $oracle_status,
			'oracle_txid'   => $oracle_txid ? substr( $oracle_txid, 0, 16 ) . '…' : '',
			'reload'        => ( $safe && 'completed' === $status && '' !== $tracking ),
		) );
	}

	/**
	 * Fetch SOL/PLN rate from public Solana price API.
	 * Stale-OK cache 1h. Called from ajax_create_session.
	 */
	protected static function fetch_sol_rate() {
		$resp = wp_remote_get(
			'https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=pln',
			array( 'timeout' => 5, 'sslverify' => true )
		);
		if ( is_wp_error( $resp ) ) {
			return 0.0;
		}
		$body = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( is_array( $body ) && isset( $body['solana']['pln'] ) ) {
			return (float) $body['solana']['pln'];
		}
		return 0.0;
	}

	/**
	 * HMAC token for VPS authentication.
	 */
	public static function generate_token( $order_id, $locker_id, $carrier ) {
		return hash_hmac( 'sha256', $order_id . '|' . $locker_id . '|' . $carrier, self::get_site_secret() );
	}

	private static function get_site_secret() {
		$secret = get_option( 'parcselect_embed_secret', '' );
		if ( empty( $secret ) ) {
			$secret = wp_generate_password( 48, true, true );
			update_option( 'parcselect_embed_secret', $secret, false );
		}
		return $secret;
	}

	/**
	 * JS bridge: relay postMessage events from iframe to WP AJAX, post results back.
	 */
	public static function bridge_js() {
		// Relaxed screen check — HPOS can produce screen IDs that don't
		// match legacy 'shop_order'. Load on every wp-admin page that has
		// a WC order context. Bridge is inert until it actually receives
		// a message from IFRAME_ORIGIN, so the overhead elsewhere is zero.
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		$is_wc_order_screen = false;
		if ( $screen ) {
			if ( in_array( $screen->id, array( 'shop_order', 'woocommerce_page_wc-orders' ), true ) ) {
				$is_wc_order_screen = true;
			}
			if ( isset( $screen->post_type ) && 'shop_order' === $screen->post_type ) {
				$is_wc_order_screen = true;
			}
		}
		// Fallback: GET params match order edit URL
		if ( ! $is_wc_order_screen
			&& ( ( isset( $_GET['page'] ) && 'wc-orders' === $_GET['page'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			     || ( isset( $_GET['post_type'] ) && 'shop_order' === $_GET['post_type'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			     || ( isset( $_GET['post'], $_GET['action'] ) && 'edit' === $_GET['action'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			   ) ) {
			$is_wc_order_screen = true;
		}
		if ( ! $is_wc_order_screen ) {
			return;
		}
		// FIX #10 (2026-05-15): bridge JS wyniesiony z inline (żeby
		// PS_LOG telemetria F12 widziała kod, a wersja podawana w konsoli
		// trzymała się PARCSELECT_VERSION). Plik: assets/js/parcselect-label-bridge.js.
		$bridge_url = PARCSELECT_PLUGIN_URL . 'assets/js/parcselect-label-bridge.js';
		$bridge_ver = defined( 'PARCSELECT_VERSION' ) ? PARCSELECT_VERSION : '1.0.0';
		wp_register_script( 'parcselect-label-bridge', $bridge_url, array(), $bridge_ver, true );
		wp_localize_script( 'parcselect-label-bridge', 'parcselectLabelBridgeCfg', array(
			'iframeOrigin' => self::IFRAME_ORIGIN,
			'nonce'        => wp_create_nonce( 'parcselect_label_consent' ),
			'version'      => $bridge_ver,
			'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
		) );
		wp_enqueue_script( 'parcselect-label-bridge' );
	}

	/**
	 * Save tracking+label received from polling into order meta. Mirrors the
	 * same keys the webhook would have written.
	 */
	public static function ajax_save_tracking() {
		check_ajax_referer( 'parcselect_label_save_tracking', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
		}
		$order_id   = absint( $_POST['order_id'] ?? 0 );
		$tracking   = sanitize_text_field( wp_unslash( $_POST['tracking_number'] ?? '' ) );
		$label      = esc_url_raw( wp_unslash( $_POST['label_url'] ?? '' ) );
		$carrier    = sanitize_text_field( wp_unslash( $_POST['carrier'] ?? '' ) );
		$txid       = sanitize_text_field( wp_unslash( $_POST['txid'] ?? '' ) );
		$crypto     = sanitize_text_field( wp_unslash( $_POST['crypto'] ?? '' ) );
		$sol_amount = sanitize_text_field( wp_unslash( $_POST['sol_amount'] ?? '' ) );
		$claimed_session = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
		$order = wc_get_order( $order_id );
		if ( ! $order || ! $tracking ) {
			wp_send_json_error( array( 'message' => 'bad_input' ), 400 );
		}

		// === v4.0.4 ZERO-LEAK ELIMINATION (root cause kill, not band-aid) ===
		//
		// Problem this kills: the iframe at map.parcelselector.com posts a
		// `parcselect_label_completed` message to the parent window with
		// {tracking, label_url, txid, sol_amount}. The bridge_js() listener
		// blindly calls THIS endpoint with whatever came in. With a shared
		// SOL wallet, the iframe (or X21 daemon backing it) can return a
		// transaction belonging to a DIFFERENT shop / order. Plugin then
		// stamps it as ours = leak.
		//
		// Hard requirements to write:
		//   1. Order MUST have a `_ps_label_session_id` previously stored
		//      via start_generation (which is admin-initiated on THIS install).
		//   2. Iframe POST MUST include `session_id` matching ours exactly.
		//   3. owns() must return TRUE OR has_any_label_meta() must be FALSE
		//      (no foreign meta to overwrite).
		//
		// Without all three, REJECT and emit forensic log.
		$our_session = (string) $order->get_meta( '_ps_label_session_id', true );
		if ( '' === $our_session ) {
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::debug_to_f12( 'ajax.save_tracking.REJECTED_no_session', $order, array(
					'reason'           => 'order has no _ps_label_session_id stored — this install never initiated a label session for this order',
					'claimed_session'  => $claimed_session,
					'iframe_payload'   => array( 'tracking' => $tracking, 'txid' => $txid, 'sol_amount' => $sol_amount ),
				) );
			}
			wp_send_json_error( array( 'message' => 'no_session_for_this_install' ), 403 );
		}
		if ( '' === $claimed_session || ! hash_equals( $our_session, $claimed_session ) ) {
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::debug_to_f12( 'ajax.save_tracking.REJECTED_session_mismatch', $order, array(
					'reason'          => 'claimed session_id does not match the session_id this install created',
					'our_session'     => substr( $our_session, 0, 12 ) . '…',
					'claimed_session' => substr( $claimed_session, 0, 12 ) . '…',
				) );
			}
			wp_send_json_error( array( 'message' => 'session_id_mismatch' ), 403 );
		}
		if ( class_exists( 'Parcselect_Label_Installation' )
			&& ! Parcselect_Label_Installation::owns( $order )
			&& Parcselect_Label_Installation::has_any_label_meta( $order ) ) {
			// Foreign meta exists but session_id did match — strange state.
			// Purge first, then proceed; the matching session_id is our
			// strongest proof of ownership.
			Parcselect_Label_Installation::purge_order( $order );
		}
		// Latest scalar meta — always refresh (the "current" label is the newest
		// one in history). History below keeps every label ever generated.
		// P2 #9 2026-05-14: (string) cast — defensywne, choć === jest bezpieczne.
		$already_latest = (string) $order->get_meta( '_ps_label_tracking_number', true ) === (string) $tracking;
		if ( ! $already_latest ) {
			$order->update_meta_data( '_ps_label_tracking_number', $tracking );
			$order->update_meta_data( '_ps_label_url', $label );
			$order->update_meta_data( '_ps_label_carrier', $carrier );
			$order->update_meta_data( '_ps_label_status', 'completed' );
			$order->update_meta_data( '_ps_label_completed_at', current_time( 'mysql' ) );
			if ( $txid )       $order->update_meta_data( '_ps_label_txid', $txid );
			if ( $crypto )     $order->update_meta_data( '_ps_label_crypto', $crypto );
			if ( $sol_amount ) $order->update_meta_data( '_ps_label_crypto_amount', $sol_amount );

			// Append to history array (dedup by tracking — never double-write).
			$history = (array) $order->get_meta( '_ps_label_history', true );
			$dup = false;
			foreach ( $history as $h ) { if ( ( $h['tracking'] ?? '' ) === $tracking ) { $dup = true; break; } }
			if ( ! $dup ) {
				$history[] = array(
					'created_at' => current_time( 'mysql' ),
					'tracking'   => $tracking,
					'label_url'  => $label,
					'txid'       => $txid,
					'crypto'     => $crypto,
					'amount'     => $sol_amount,
					'carrier'    => $carrier,
				);
				$order->update_meta_data( '_ps_label_history', $history );
			}
			$order->save();
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::stamp( $order );
			}
			$order->add_order_note( sprintf(
				/* translators: 1: tracking number, 2: carrier name */
				esc_html__( 'Label generated (polling). Tracking: %1$s (%2$s)', 'parcel-selector' ),
				$tracking, $carrier
			) );
		}
		wp_send_json_success( array( 'saved' => true ) );
	}
}

// Auto-init at file load.
Parcselect_Label_Embed::init();
