<?php
/**
 * ETYKIETOWNIK AJAX handlers.
 *
 * All actions: get_price, create_session, check_status, generate_label, download_label.
 *
 * @package ParcelSelector
 * @since   3.4.0
 */

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

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

class Parcselect_Label_Ajax {

	public static function init() {
		add_action( 'wp_ajax_parcselect_label_check_status', array( __CLASS__, 'check_status' ) );
		add_action( 'wp_ajax_parcselect_label_download_label', array( __CLASS__, 'download_label' ) );
		add_action( 'wp_ajax_parcselect_label_recheck_payment', array( __CLASS__, 'recheck_payment' ) );
		add_action( 'wp_ajax_parcselect_label_retry_generation', array( __CLASS__, 'retry_generation' ) );
		add_action( 'wp_ajax_parcselect_label_iframe_event', array( __CLASS__, 'iframe_event' ) );
		add_action( 'wp_ajax_parcselect_label_regenerate_download_nonce', array( __CLASS__, 'regenerate_download_nonce' ) );
		// Consent + sender save — called by JS bridge after iframe postMessage.

		// Global AJAX timing — wraps EVERY ps_label_* action with entry/exit
		// log that includes request_id + duration. Runs BEFORE each handler.
		foreach ( array( 'check_status', 'download_label', 'recheck_payment', 'retry_generation',
		) as $_action ) {
			add_action( 'wp_ajax_parcselect_label_' . $_action, array( __CLASS__, 'log_ajax_entry_' . $_action ), 1 );
		}
	}

	/** Called via add_action at priority 1 (before the real handler). */
	public static function log_ajax_entry( $action ) {
		if ( ! class_exists( 'Parcselect_Label_Debug' ) || ! Parcselect_Label_Debug::enabled() ) {
			return;
		}
		$rid_from_header = isset( $_SERVER['HTTP_X_PS_REQUEST_ID'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_PS_REQUEST_ID'] ) ) : '';
		$oid = absint( wp_unslash( $_POST['order_id'] ?? 0 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- logujacy hook priorytetu 1; nonce sprawdza wlasciwy handler AJAX (check_ajax_referer), wartosc uzyta tylko do logu
		Parcselect_Label_Debug::info( $oid ?: null, 'ajax.' . $action . '.entry', array(
			'rid'       => $rid_from_header ?: Parcselect_Label_Debug::request_id(),
			'has_order' => (bool) $oid,
		) );
		// Track start time for duration on shutdown.
		if ( function_exists( 'register_shutdown_function' ) ) {
			$start = microtime( true );
			register_shutdown_function( function () use ( $action, $oid, $start ) {
				$dur = (int) ( ( microtime( true ) - $start ) * 1000 );
				Parcselect_Label_Debug::info( $oid ?: null, 'ajax.' . $action . '.exit', array( 'duration_ms' => $dur ) );
			} );
		}
	}

	// Per-action stubs (PHP can't curry method refs for add_action easily).
	public static function log_ajax_entry_check_status()       { self::log_ajax_entry( 'check_status' ); }
	public static function log_ajax_entry_download_label()     { self::log_ajax_entry( 'download_label' ); }
	public static function log_ajax_entry_recheck_payment()    { self::log_ajax_entry( 'recheck_payment' ); }
	public static function log_ajax_entry_retry_generation()   { self::log_ajax_entry( 'retry_generation' ); }

	/**
	 * Normalize whatever URL shape the VPS sends back (absolute, root-relative,
	 * protocol-relative) into a fully-qualified URL pointing at the configured
	 * map-server origin. Logs the decision so a future schema change is visible.
	 */
	public static function normalize_vps_url( $url ) {
		$url  = (string) $url;
		$base = rtrim( (string) get_option( 'parcselect_label_mapserver_url', get_option( 'parcselect_label_proxy_url', 'https://map.parcelselector.com' ) ), '/' );
		if ( '' === $url ) return '';
		// Already absolute: http(s)://...
		if ( preg_match( '#^https?://#i', $url ) ) {
			return $url;
		}
		// Protocol-relative: //host/path → use https.
		if ( 0 === strpos( $url, '//' ) ) {
			return 'https:' . $url;
		}
		// Root-relative: /path → prepend base.
		if ( 0 === strpos( $url, '/' ) ) {
			return $base . $url;
		}
		// Bare path, e.g. embed/label/download/xxx → assume root.
		return $base . '/' . $url;
	}

	/**
	 * Sum line-item weights into a kg total. Falls back to product shipping
	 * weight if line meta is empty. Returns 0.0 on missing data so callers
	 * can apply their own default (admin option `ps_label_default_weight`).
	 */
	public static function compute_order_weight( $order ) {
		if ( ! $order || ! method_exists( $order, 'get_items' ) ) {
			return 0.0;
		}
		$total_kg = 0.0;
		$unit     = function_exists( 'get_option' ) ? get_option( 'woocommerce_weight_unit', 'kg' ) : 'kg';
		foreach ( $order->get_items( 'line_item' ) as $item ) {
			if ( ! $item instanceof WC_Order_Item_Product ) continue;
			$qty = (int) $item->get_quantity();
			$prod = $item->get_product();
			if ( ! $prod ) continue;
			$w = (float) $prod->get_weight();
			if ( $w <= 0 ) continue;
			$total_kg += $w * $qty;
		}
		// Convert from store unit to kg if necessary.
		switch ( strtolower( (string) $unit ) ) {
			case 'g':  $total_kg = $total_kg / 1000.0; break;
			case 'lbs': $total_kg = $total_kg * 0.453592; break;
			case 'oz':  $total_kg = $total_kg * 0.0283495; break;
		}
		return round( $total_kg, 3 );
	}

	/**
	 * Pre-flight receiver validation. shipping provider rejects orders silently
	 * with "Pole nie może być puste" — we'd rather fail before the SOL
	 * payment than after.
	 *
	 * Returns array of missing fields (empty = OK).
	 */
	public static function missing_receiver_fields( $order ) {
		$missing = array();
		if ( ! $order ) return array( 'order' );
		$shipping = $order->get_address( 'shipping' );
		$first    = trim( ( $shipping['first_name'] ?? '' ) . '' );
		$last     = trim( ( $shipping['last_name'] ?? '' ) . '' );
		$name     = trim( $first . ' ' . $last );
		if ( '' === $name ) {
			$bf = $order->get_billing_first_name() . ' ' . $order->get_billing_last_name();
			$name = trim( $bf );
		}
		if ( '' === $name ) $missing[] = 'name';

		$phone = method_exists( $order, 'get_shipping_phone' ) ? $order->get_shipping_phone() : '';
		if ( ! $phone ) $phone = $order->get_billing_phone();
		if ( '' === trim( (string) $phone ) ) $missing[] = 'phone';

		$email = (string) $order->get_billing_email();
		// sanitize_email + filter_var double-check — catches "test@local" etc
		// that pass sanitize_email but shipping provider still rejects as malformed.
		if ( '' === $email || ! is_email( $email ) ) $missing[] = 'email';

		return $missing;
	}

	/**
	 * AJAX: JS bridge writes consent_service=yes to order meta on behalf of
	 * the VPS iframe (which posted `ps_label_consent {step:"service"}` to parent).
	 * Iframe expects a `ps_label_consent_result {data:{success:true|false}}`
	 * message — bridge turns OUR JSON response into that postMessage.
	 */
	/**
	 * AJAX: Browser-side iframe load events. Mirrors JS-side decisions
	 * (skip / render.attempt / render.onload_ok / render.timeout /
	 * fallback.native) into the server-side trail so the metabox accordion
	 * shows the same story as F12 — no need to ask the admin "open dev tools".
	 */
	public static function iframe_event() {
		// v4.2.1 BEZWZGLEDNY consent gate — iframe_event blokowany bez Stage 1 consent.
		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_nonce', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'error' => 'Unauthorized' ) );
		}
		$order_id = absint( wp_unslash( $_POST['order_id'] ?? 0 ) );
		$stage    = sanitize_text_field( wp_unslash( $_POST['stage'] ?? 'unknown' ) );
		$data_raw = wp_unslash( $_POST['data'] ?? '{}' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- surowy payload JSON, walidowany/dekodowany ponizej
		$data     = json_decode( (string) $data_raw, true );
		if ( ! is_array( $data ) ) {
			$data = array();
		} else {
			// Recursively sanitize all string values; preserves arrays/booleans/ints structure.
			$data = map_deep( $data, 'sanitize_text_field' );
		}
		$level    = ( strpos( $stage, 'fallback' ) === 0 || strpos( $stage, 'render.timeout' ) === 0 || strpos( $stage, 'render.onerror' ) === 0 )
			? 'warning'
			: ( strpos( $stage, 'skip.missing' ) === 0 ? 'error' : 'info' );
		Parcselect_Label_Debug::log( $order_id ?: null, 'iframe.' . $stage, $data, $level );
		wp_send_json_success( array( 'logged' => true ) );
	}

	/**
	 * AJAX: regenerate the per-order download nonce. Old approach embedded
	 * the nonce in check_status response — if the admin sat on the page >24h
	 * the nonce expired and the Download button died with "Invalid security
	 * token" and no recovery path. The metabox JS calls this when the
	 * download fails, gets a fresh nonce, retries.
	 */
	public static function regenerate_download_nonce() {
		check_ajax_referer( 'parcselect_label_nonce', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'error' => 'Unauthorized' ) );
		}
		$order_id = absint( wp_unslash( $_POST['order_id'] ?? 0 ) );
		if ( ! $order_id ) {
			wp_send_json_error( array( 'error' => 'No order_id' ) );
		}
		$fresh = wp_create_nonce( 'parcselect_label_download_' . $order_id );
		Parcselect_Label_Debug::info( $order_id, 'download.nonce.regenerated', array( 'len' => strlen( $fresh ) ) );
		wp_send_json_success( array(
			'nonce'        => $fresh,
			'download_url' => admin_url( 'admin-ajax.php?action=parcselect_label_download_label&order_id=' . $order_id . '&nonce=' . $fresh ),
		) );
	}

	/**
	 * AJAX: Get price for a given size and weight.
	 */
	/**
	 * AJAX: Create a payment session — calls VPS, returns VPS-issued wallet + QR + amount.
	 *
	 * The VPS is the single source of truth for price (in PLN, with margin applied)
	 * and for the crypto amount to send (PLN → SOL via live rate). We never compute
	 * these locally — that prevents a rogue plugin-side mutation from leading to a
	 * short-paid label.
	 */
	/**
	 * AJAX: admin hits "Sprawdź płatność" button — asks VPS to re-verify on-chain
	 * using 2-of-3 consensus across Solana RPC providers. Used when admin paid
	 * the session wallet after the session's short TTL expired.
	 */
	public static function recheck_payment() {
		// v4.2.1 BEZWZGLEDNY consent gate — recheck_payment blokowany bez Stage 1 consent.
		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_nonce', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'error' => 'Unauthorized' ) );
		}
		$order_id = absint( wp_unslash( $_POST['order_id'] ?? 0 ) );
		// QW#12 — strip whitespace and control chars; copy-paste from Phantom
		// often includes trailing newline or zero-width chars that break regex
		// on the VPS side.
		$txid     = sanitize_text_field( wp_unslash( $_POST['txid'] ?? '' ) );
		$txid     = preg_replace( '/\s+/', '', $txid );
		$order    = wc_get_order( $order_id );
		if ( ! $order ) {
			wp_send_json_error( array( 'error' => 'Order not found' ) );
		}
		// Cross-install leak guard — never query VPS with a stale session_id.
		if ( class_exists( 'Parcselect_Label_Installation' ) && ! Parcselect_Label_Installation::owns( $order ) ) {
			if ( Parcselect_Label_Installation::has_any_label_meta( $order ) ) {
				Parcselect_Label_Installation::purge_order( $order );
			}
			wp_send_json_error( array( 'error' => __( 'This label does not belong to this plugin installation.', 'parcel-selector' ) ), 403 );
		}
		$session_id = $order->get_meta( '_ps_label_session_id', true );
		if ( empty( $session_id ) ) {
			wp_send_json_error( array( 'error' => 'No session on this order — create one first.' ) );
		}
		Parcselect_Label_Debug::info( $order_id, 'ajax.recheck.start', array(
			'session_id' => $session_id, 'has_txid' => ! empty( $txid ),
		) );

		$base = trim( (string) get_option( 'parcselect_label_mapserver_url', get_option( 'parcselect_label_proxy_url', 'https://map.parcelselector.com' ) ) );
		$base = rtrim( $base, '/' );
		$res  = wp_remote_post(
			$base . '/embed/label/recheck',
			array(
				'timeout'   => 45,
				'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
				'headers'   => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json' ),
				'body'      => wp_json_encode( array( 'session_id' => $session_id, 'txid' => $txid ) ),
			)
		);
		if ( is_wp_error( $res ) ) {
			Parcselect_Label_Debug::error( $order_id, 'ajax.recheck.network_error', array( 'msg' => $res->get_error_message() ) );
			wp_send_json_error( array( 'error' => 'VPS unreachable: ' . $res->get_error_message() ) );
		}
		$body = json_decode( wp_remote_retrieve_body( $res ), true );
		$code = (int) wp_remote_retrieve_response_code( $res );
		Parcselect_Label_Debug::info( $order_id, 'ajax.recheck.response', array( 'http' => $code, 'ok' => ! empty( $body['ok'] ), 'status' => $body['status'] ?? '', 'detail' => $body['detail'] ?? '' ) );

		// === v4.0.5 ZERO-LEAK — VPS response MUST echo our installation_id ===
		// AND MUST echo back the same session_id we sent. Otherwise: REJECT.
		// Without this, a compromised / shared-wallet VPS can return another
		// shop's TX/payment data and we would write it as ours.
		$_our_iid = (string) get_option( 'parcselect_installation_id', '' );
		$_resp_iid = is_array( $body ) ? (string) ( $body['installation_id'] ?? '' ) : '';
		$_resp_sid = is_array( $body ) ? (string) ( $body['session_id'] ?? '' ) : '';
		if ( '' === $_resp_iid || '' === $_our_iid || ! hash_equals( $_our_iid, $_resp_iid )
		     || '' === $_resp_sid || ! hash_equals( (string) $session_id, $_resp_sid ) ) {
			Parcselect_Label_Debug::error( $order_id, 'ajax.recheck.REJECTED_response_unverified', array(
				'reason'           => 'VPS response missing or mismatched installation_id / session_id echo',
				'our_iid_present'  => '' !== $_our_iid,
				'resp_iid_present' => '' !== $_resp_iid,
				'iid_match'        => '' !== $_resp_iid && hash_equals( $_our_iid, $_resp_iid ),
				'sid_match'        => '' !== $_resp_sid && hash_equals( (string) $session_id, $_resp_sid ),
			) );
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::debug_to_f12( 'ajax.recheck.REJECTED', $order, array(
					'reason' => 'VPS response did not echo installation_id + session_id correctly',
				) );
			}
			wp_send_json_error( array(
				'error'  => 'response_unverified',
				'detail' => 'VPS response did not include verified installation_id + session_id echo. Refusing to write potentially foreign data.',
				'_debug' => Parcselect_Label_Debug::for_response( $order_id ),
			), 422 );
		}

		if ( 200 === $code && ! empty( $body['ok'] ) ) {
			if ( ! empty( $body['txid'] ) ) {
				$clean_txid = sanitize_text_field( $body['txid'] );
				// Prevent TXID replay: check if this exact TXID was already used on ANY order.
				$existing = wc_get_orders( array(
					'limit'      => 1,
					'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- zapytanie administracyjne, nie hot-path
						array( 'key' => '_ps_label_txid', 'value' => $clean_txid ),
					),
					'return' => 'ids',
				) );
				if ( ! empty( $existing ) && ! in_array( $order_id, $existing, true ) ) {
					// TXID already used on a different order — potential replay attack.
					Parcselect_Label_Debug::error( $order_id, 'security.txid_replay', array( 'txid' => substr( $clean_txid, 0, 16 ), 'used_by' => $existing[0] ) );
					wp_send_json_error( array( 'message' => 'TXID already used' ), 409 );
				}
				$order->update_meta_data( '_ps_label_txid', $clean_txid );
			}
			$order->update_meta_data( '_ps_label_status', 'paid' );
			$order->update_meta_data( '_ps_label_paid_at', current_time( 'mysql' ) );
			$order->save();
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::stamp( $order );
			}
			wp_send_json_success( array(
				'status' => $body['status'] ?? 'paid',
				'txid'   => $body['txid'] ?? '',
				'note'   => $body['note'] ?? 'Consensus verified on-chain.',
				'_debug' => Parcselect_Label_Debug::for_response( $order_id ),
			) );
		}
		wp_send_json_error( array(
			'error'  => $body['error'] ?? 'Recheck failed',
			'detail' => $body['detail'] ?? '',
			'_debug' => Parcselect_Label_Debug::for_response( $order_id ),
		) );
	}

	/**
	 * AJAX: admin hits "Retry" after shipping provider/generation failure on VPS.
	 * Sends POST to /embed/label/retry which re-runs generateEmbedLabel with
	 * the same session+TXID (no re-payment needed). Rate-limited on VPS to
	 * 3 retries/session/hour.
	 */
	public static function retry_generation() {
		// v4.2.1 BEZWZGLEDNY consent gate — retry_generation blokowany bez Stage 1 consent.
		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_nonce', 'nonce' );
		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( array( 'error' => 'Unauthorized' ) );
		}
		$order_id = absint( wp_unslash( $_POST['order_id'] ?? 0 ) );
		$order    = wc_get_order( $order_id );
		if ( ! $order ) {
			wp_send_json_error( array( 'error' => 'Order not found' ) );
		}
		// Cross-install leak guard.
		if ( class_exists( 'Parcselect_Label_Installation' ) && ! Parcselect_Label_Installation::owns( $order ) ) {
			if ( Parcselect_Label_Installation::has_any_label_meta( $order ) ) {
				Parcselect_Label_Installation::purge_order( $order );
			}
			wp_send_json_error( array( 'error' => __( 'This label does not belong to this plugin installation.', 'parcel-selector' ) ), 403 );
		}
		$session_id = $order->get_meta( '_ps_label_session_id', true );
		if ( empty( $session_id ) ) {
			wp_send_json_error( array( 'error' => 'No session on this order.' ) );
		}
		Parcselect_Label_Debug::info( $order_id, 'ajax.retry.start', array( 'session_id' => $session_id ) );

		$base = trim( (string) get_option( 'parcselect_label_mapserver_url', get_option( 'parcselect_label_proxy_url', 'https://map.parcelselector.com' ) ) );
		$base = rtrim( $base, '/' );
		$res  = wp_remote_post(
			$base . '/embed/label/retry',
			array(
				'timeout'   => 30,
				'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
				'headers'   => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json' ),
				'body'      => wp_json_encode( array( 'session_id' => $session_id ) ),
			)
		);
		if ( is_wp_error( $res ) ) {
			Parcselect_Label_Debug::error( $order_id, 'ajax.retry.network_error', array( 'msg' => $res->get_error_message() ) );
			wp_send_json_error( array( 'error' => 'VPS unreachable: ' . $res->get_error_message() ) );
		}
		$body = json_decode( wp_remote_retrieve_body( $res ), true );
		$code = (int) wp_remote_retrieve_response_code( $res );
		Parcselect_Label_Debug::info( $order_id, 'ajax.retry.response', array( 'http' => $code, 'ok' => ! empty( $body['ok'] ), 'status' => $body['status'] ?? '' ) );

		if ( 200 === $code && ! empty( $body['ok'] ) ) {
			$order->update_meta_data( '_ps_label_status', $body['status'] ?? 'generating' );
			$order->delete_meta_data( '_ps_label_error' );
			$order->save();
			if ( class_exists( 'Parcselect_Label_Installation' ) ) {
				Parcselect_Label_Installation::stamp( $order );
			}
			wp_send_json_success( array(
				'status'      => $body['status'] ?? 'generating',
				'retry_count' => $body['retry_count'] ?? 1,
				'_debug'      => Parcselect_Label_Debug::for_response( $order_id ),
			) );
		}
		wp_send_json_error( array(
			'error'  => $body['error'] ?? 'Retry failed',
			'_debug' => Parcselect_Label_Debug::for_response( $order_id ),
		) );
	}

	/**
	 * AJAX: Check label status (polling from admin JS).
	 */
	public static function check_status() {
		// v4.2.1 BEZWZGLEDNY consent gate — check_status blokowany bez Stage 1 consent.
		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_nonce', 'nonce' );

		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$order_id = absint( wp_unslash( $_POST['order_id'] ?? 0 ) );
		$order    = wc_get_order( $order_id );

		if ( ! $order ) {
			wp_send_json_error( 'Order not found' );
		}

		// Cross-install leak guard — purge then return clean status so UI
		// re-renders the fresh consent flow instead of stale "completed".
		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 );
		}

		$status = $order->get_meta( '_ps_label_status', true );

		// SHORT-CIRCUIT — when local meta is already 'completed' AND we have
		// the tracking + label artefacts, return immediately without burning
		// a VPS round-trip. Webhook /ps/v1/label-callback updates the meta
		// reliably; polling exists only as a fallback. This kills the
		// historic 36×check_status_vps_call burst on every payment.
		// Drift detection (VPS says expired but local says completed) is
		// handled by retry_generation / cleanup_expired cron, not polling.
		$_local_tracking  = (string) $order->get_meta( '_ps_label_tracking_number', true );
		$_local_label_url = (string) $order->get_meta( '_ps_label_url', true );
		if ( 'completed' === $status && '' !== $_local_tracking && '' !== $_local_label_url ) {
			wp_send_json_success( array(
				'status'         => 'completed',
				'tracking_number' => $_local_tracking,
				'label_url'      => admin_url( 'admin-ajax.php?action=parcselect_label_download_label&order_id=' . $order_id . '&nonce=' . wp_create_nonce( 'parcselect_label_download_' . $order_id ) ),
				'source'         => 'local_meta_short_circuit',
				'_debug'         => class_exists( 'Parcselect_Label_Debug' ) ? Parcselect_Label_Debug::for_response( $order_id ) : null,
			) );
		}

		// QW#1 — polling fallback for when VPS webhook can't reach us (local dev,
		// staging behind auth, network partition). Every poll, if we have a
		// session_id but the status isn't "completed" yet, ask the VPS what the
		// TRUTH is. If VPS says completed, pull tracking/label_url straight from
		// there and mirror into our meta — no need for the webhook.
		$session_id_local = $order->get_meta( '_ps_label_session_id', true );
		// Poll VPS only when local state is NOT completed (handled above).
		if ( $session_id_local && class_exists( 'Parcselect_Label_Session_Client' ) ) {
			$vps_status = Parcselect_Label_Session_Client::get_status( $session_id_local, $order_id );
			$vps_status_str = is_array( $vps_status ) ? ( $vps_status['status'] ?? 'n/a' ) : 'wp_error';
			Parcselect_Label_Debug::info( $order_id, 'check_status.vps_polled', array(
				'vps_status'       => $vps_status_str,
				'has_tracking'     => is_array( $vps_status ) && ! empty( $vps_status['tracking_number'] ),
				'has_label_url'    => is_array( $vps_status ) && ! empty( $vps_status['label_url'] ),
				'response_keys'    => is_array( $vps_status ) ? array_slice( array_keys( $vps_status ), 0, 10 ) : array(),
				'local_meta_status' => $status,
			) );
			// Drift detection: local says completed but VPS doesn't recognise
			// the session (expired/missing). PDF still served from local mirror
			// or integrator-disk fallback, but admin should know.
			if ( 'completed' === $status && in_array( $vps_status_str, array( 'expired', 'missing', 'not_found', 'wp_error' ), true ) ) {
				Parcselect_Label_Debug::warn( $order_id, 'check_status.drift_detected', array(
					'local'       => 'completed',
					'vps'         => $vps_status_str,
					'consequence' => 'PDF served from local/integrator-disk fallback; VPS no longer remembers this session',
				) );
			}
			if ( is_array( $vps_status ) && isset( $vps_status['status'] ) && 'completed' === $vps_status['status'] ) {
				// === v4.0.5 ZERO-LEAK — VPS status response MUST echo our installation_id + session_id ===
				$_our_iid  = (string) get_option( 'parcselect_installation_id', '' );
				$_resp_iid = (string) ( $vps_status['installation_id'] ?? '' );
				$_resp_sid = (string) ( $vps_status['session_id'] ?? '' );
				if ( '' === $_resp_iid || '' === $_our_iid || ! hash_equals( $_our_iid, $_resp_iid )
				     || '' === $_resp_sid || ! hash_equals( (string) $session_id_local, $_resp_sid ) ) {
					Parcselect_Label_Debug::error( $order_id, 'check_status.REJECTED_response_unverified', array(
						'reason'           => 'VPS get_status response missing or mismatched installation_id / session_id echo',
						'our_iid_present'  => '' !== $_our_iid,
						'resp_iid_present' => '' !== $_resp_iid,
						'iid_match'        => '' !== $_resp_iid && hash_equals( $_our_iid, $_resp_iid ),
						'sid_match'        => '' !== $_resp_sid && hash_equals( (string) $session_id_local, $_resp_sid ),
					) );
					if ( class_exists( 'Parcselect_Label_Installation' ) ) {
						Parcselect_Label_Installation::debug_to_f12( 'check_status.REJECTED', $order, array(
							'reason' => 'VPS get_status response did not echo installation_id + session_id correctly',
						) );
					}
					// Skip the write — fall through and report local status only.
				} else {
				$trk = isset( $vps_status['tracking_number'] ) ? sanitize_text_field( $vps_status['tracking_number'] ) : '';
				$url = isset( $vps_status['label_url'] ) ? esc_url_raw( $vps_status['label_url'] ) : '';
				if ( $trk ) {
					$order->update_meta_data( '_ps_label_tracking_number', $trk );
				}
				if ( $url ) {
					// v4.2 B.3 SPEC/10 sekcja 11 — Pdf_Store fetch przed save meta
					if ( class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
						$_tracking = isset( $vps_status['tracking_number'] ) ? (string) $vps_status['tracking_number'] : '';
						$_pdf_local = Parcselect_Label_Pdf_Store::fetch_and_store( $order, $url, $_tracking );
						if ( ! is_wp_error( $_pdf_local ) ) {
							$url = $_pdf_local; // override: meta dostaje LOCAL URL
						}
					}
					$order->update_meta_data( '_ps_label_url', $url );
				}
				$order->update_meta_data( '_ps_label_status', 'completed' );
				$order->update_meta_data( '_ps_label_completed_at', current_time( 'mysql' ) );
				$order->save();
				if ( class_exists( 'Parcselect_Label_Installation' ) ) {
					Parcselect_Label_Installation::stamp( $order );
				}
				$status = 'completed';
				}
				Parcselect_Label_Debug::info( $order_id, 'check_status.polled_vps_completed', array( 'session_id' => $session_id_local, 'tracking' => $trk ) );
			}
		}

		// Auto-trigger: if status is 'paid' but label not yet generating, start generation now.
		// This handles the case where payment was confirmed but generation failed or was never triggered.
		if ( 'paid' === $status && ! get_transient( 'parcselect_generating_' . $order_id ) ) {
			Parcselect_Label_Hooks::log( 'Auto-trigger label generation for order #' . $order_id . ' (status=paid, triggered by check_status poll)' );
			self::do_generate_label( $order_id );
			// Re-read status after generation attempt.
			$order = wc_get_order( $order_id );
			$status = $order->get_meta( '_ps_label_status', true );
		}

		$result = array( 'status' => $status ? $status : 'none' );

		if ( 'completed' === $status ) {
			$result['tracking_number'] = $order->get_meta( '_ps_label_tracking_number', true );
			$result['label_url']       = admin_url(
				'admin-ajax.php?action=parcselect_label_download_label&order_id=' . $order_id
				. '&nonce=' . wp_create_nonce( 'parcselect_label_download_' . $order_id )
			);
			$result['price_display'] = $order->get_meta( '_ps_label_price_display', true );
			$result['sol_amount']    = $order->get_meta( '_ps_label_sol_amount', true );
			$result['txid']          = $order->get_meta( '_ps_label_txid', true );
			$result['size']          = $order->get_meta( '_ps_label_size', true );
			$result['weight']        = $order->get_meta( '_ps_label_weight', true );
		} elseif ( 'awaiting_payment' === $status ) {
			// Include QR data so JS can re-render QR after page reload.
			$result['sol_amount']    = $order->get_meta( '_ps_label_sol_amount', true );
			$result['sol_address']   = $order->get_meta( '_ps_label_sol_wallet', true );
			$result['qr_uri']        = $order->get_meta( '_ps_label_qr_uri', true );
			$result['price_display'] = $order->get_meta( '_ps_label_price_display', true );
			$created = $order->get_meta( '_ps_label_created_at', true );
			$elapsed = $created ? ( time() - strtotime( $created ) ) : 0;
			$result['timeout_remaining'] = max( 0, 1800 - $elapsed );
		} elseif ( 'error' === $status ) {
			$result['error_message'] = self::humanize_error( $order->get_meta( '_ps_label_error', true ) );
		}

		wp_send_json_success( $result );
	}

	/**
	 * AJAX: Save inline settings from metabox checklist.
	 * Admin fills in missing config right in the order page — no redirect needed.
	 */
	/**
	 * AJAX: Get crypto payment options for label payment.
	 *
	 * Uses Parcselect_API::get_enabled_coins() and Parcselect_API::convert_detailed()
	 * to build structured JSON + pre-rendered payment cards using the
	 * exact same CSS classes as PS CryptoPay checkout.css.
	 */
	/**
	 * AJAX: Save selected cryptocurrency to order meta + build QR data.
	 */
	/**
	 * AJAX: Check if label payment has been confirmed.
	 * Polled by JS every 10 seconds.
	 *
	 * SELF-CONTAINED: If status is still awaiting_payment, the plugin checks
	 * blockchain explorers directly — no external daemon required.
	 * Works on ANY WordPress installation (local, staging, production).
	 */
	/**
	 * AJAX: Render NATIVE PS CryptoPay payment UI for selected coin.
	 * Saves crypto data in _pscp_* format and calls thankyou_page() renderer.
	 * Returns IDENTICAL HTML as the checkout thank-you page.
	 */
	/**
	 * AJAX: Reset a failed/expired session so admin can try again.
	 */
	/**
	 * Generate label via label API.
	 * Called internally after payment confirmation.
	 * Flow: create_package → order_package → get_label → save PDF.
	 *
	 * @param int $order_id WooCommerce order ID.
	 * @return bool True on success.
	 */
	public static function do_generate_label( $order_id ) {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return false;
		}

		// GUARD 1: Never generate twice — if label already completed OR generating, stop.
		$current_status = $order->get_meta( '_ps_label_status', true );
		if ( in_array( $current_status, array( 'completed', 'generating' ), true ) ) {
			Parcselect_Label_Hooks::log( 'Generation BLOCKED order #' . $order_id . ': status=' . $current_status, 'warning' );
			return false;
		}

		// GUARD 2: Payment must be verified — txid must exist (prevents unpaid label generation).
		$txid = $order->get_meta( '_ps_label_txid', true );
		if ( empty( $txid ) ) {
			Parcselect_Label_Hooks::log( 'Generation BLOCKED order #' . $order_id . ': no txid — payment not verified', 'warning' );
			return false;
		}

		// GUARD 3: TXID must not be used by another order (replay attack prevention).
		// Supports both HPOS (wc_orders_meta) and Classic (postmeta) storage.
		global $wpdb;
		$_replay_count = 0;
		$_hpos_table   = $wpdb->prefix . 'wc_orders_meta';
		$_post_table   = $wpdb->postmeta;

		// Check HPOS table first (primary for WC 8.2+).
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $_hpos_table ) ) === $_hpos_table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bezposrednie zapytanie do tabeli HPOS WooCommerce, brak API; wynik jednorazowy, cache zbedny
			$_replay_count = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- nazwa tabeli z $wpdb->prefix (zaufana); wartosci przekazane przez $wpdb->prepare()
				"SELECT COUNT(*) FROM {$_hpos_table} WHERE meta_key = '_ps_label_txid' AND meta_value = %s AND order_id != %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- nazwa tabeli HPOS z $wpdb->prefix (zaufana, nie input); wartosci przez %s/%d.
				$txid, $order_id
			) );
		}

		// Also check classic postmeta (fallback / hybrid mode).
		if ( 0 === $_replay_count ) {
			$_replay_count = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- nazwa tabeli z $wpdb->prefix (zaufana); wartosci przekazane przez $wpdb->prepare()
				"SELECT COUNT(*) FROM {$_post_table} WHERE meta_key = '_ps_label_txid' AND meta_value = %s AND post_id != %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- nazwa tabeli z $wpdb->postmeta (zaufana, nie input); wartosci przez %s/%d.
				$txid, $order_id
			) );
		}

		if ( $_replay_count > 0 ) {
			Parcselect_Label_Hooks::log( 'Generation BLOCKED order #' . $order_id . ': TXID REPLAY detected — ' . substr( $txid, 0, 16 ) . '...', 'error' );
			self::set_error( $order, __( 'Payment verification failed: transaction already used for another order.', 'parcel-selector' ) );
			return false;
		}

		// GUARD 4: Concurrent generation lock — prevent double label from daemon + admin race.
		$lock_key = 'parcselect_generating_' . $order_id;
		if ( get_transient( $lock_key ) ) {
			Parcselect_Label_Hooks::log( 'Generation SKIPPED order #' . $order_id . ': lock active (concurrent request)', 'warning' );
			return false;
		}
		set_transient( $lock_key, 1, 120 ); // 2 minute lock.

		// STEP 0: Validate data before sending to shipping provider.
		$validation = Parcselect_Label_Hooks::validate_order_for_label( $order_id );
		if ( ! $validation['ok'] ) {
			$error_msg = implode( ' | ', $validation['errors'] );
			Parcselect_Label_Hooks::log( 'Validation failed for order #' . $order_id . ': ' . $error_msg, 'error' );
			delete_transient( $lock_key );
			self::set_error( $order, $error_msg );
			return false;
		}

		$size   = $order->get_meta( '_ps_label_size', true );
		$size   = $size ? $size : 'A';
		$weight = (float) $order->get_meta( '_ps_label_weight', true );
		$weight = $weight > 0 ? $weight : 1.0;

		// Detect carrier (from _ps_carrier meta or auto-detect from locker name).
		$carrier    = Parcselect_Label_Carrier::detect( $order );
		$service_id = Parcselect_Label_Carrier::get_service_id( $carrier );
		$sizes      = Parcselect_Label_Carrier::get_sizes( $carrier );
		$size_cfg   = isset( $sizes[ $size ] ) ? $sizes[ $size ] : reset( $sizes );
		$dimensions = array(
			'width'  => isset( $size_cfg['width'] )  ? $size_cfg['width']  : 64,
			'height' => isset( $size_cfg['height'] ) ? $size_cfg['height'] : 8,
			'depth'  => isset( $size_cfg['depth'] )  ? $size_cfg['depth']  : 38,
		);

		// Save detected carrier to meta for reference.
		$order->update_meta_data( '_ps_label_carrier', $carrier );
		$order->update_meta_data( '_ps_label_status', 'generating' );
		$order->save();
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}

		Parcselect_Label_Hooks::log( 'Generating label for order #' . $order_id . ' (carrier: ' . $carrier . ', service_id: ' . $service_id . ', size: ' . $size . ', weight: ' . $weight . ')' );

		// STEP 1: Create package (draft) via label service.
		$package = Parcselect_Label_API::create_package( $order_id, $dimensions, $weight, $service_id );

		if ( ! $package || isset( $package['error'] ) ) {
			$error_msg = isset( $package['message'] ) ? $package['message'] : 'Label service: package creation failed';
			Parcselect_Label_Hooks::log( 'STEP 1 FAILED order #' . $order_id . ': ' . $error_msg, 'error' );
			delete_transient( $lock_key );
			self::set_error( $order, $error_msg );
			return false;
		}

		$package_id      = isset( $package['package_id'] ) ? $package['package_id'] : ( isset( $package['id'] ) ? $package['id'] : '' );
		$tracking_number = isset( $package['tracking_number'] ) ? $package['tracking_number'] : ( isset( $package['tracking'] ) ? $package['tracking'] : '' );

		Parcselect_Label_Hooks::log( 'STEP 1 OK order #' . $order_id . ': package_id=' . $package_id . ', tracking=' . $tracking_number );

		if ( empty( $package_id ) ) {
			Parcselect_Label_Hooks::log( 'STEP 1 no package_id in response: ' . wp_json_encode( $package ), 'error' );
			delete_transient( $lock_key );
			self::set_error( $order, 'Label service: no package ID returned' );
			return false;
		}

		$order->update_meta_data( '_ps_label_package_id', $package_id );
		$order->update_meta_data( '_ps_label_shipping provider_id', $package_id ); // backward compat alias
		$order->save();

		// STEP 2: Order the package (finalize) — required before label generation.
		$order_result = Parcselect_Label_API::order_package( $package_id );

		if ( ! $order_result || isset( $order_result['error'] ) ) {
			$error_msg = isset( $order_result['message'] ) ? $order_result['message'] : 'Label service: order finalization failed';
			Parcselect_Label_Hooks::log( 'STEP 2 FAILED order #' . $order_id . ': ' . $error_msg, 'error' );
			delete_transient( $lock_key );
			self::set_error( $order, $error_msg );
			return false;
		}

		Parcselect_Label_Hooks::log( 'STEP 2 OK order #' . $order_id . ': package ordered' );

		// If ordering is async, the tracking number may appear after ordering.
		if ( empty( $tracking_number ) && isset( $order_result['tracking_number'] ) ) {
			$tracking_number = $order_result['tracking_number'];
		}

		// STEP 3: Wait briefly then fetch label (shipping provider may need a moment to generate).
		sleep( 2 );

		$label_result = Parcselect_Label_API::get_label( $package_id );

		if ( ! $label_result ) {
			// Try once more after a short delay.
			sleep( 3 );
			$label_result = Parcselect_Label_API::get_label( $package_id );
		}

		$pdf = is_array( $label_result ) ? ( $label_result['pdf'] ?? '' ) : '';

		// VPS proxy returns tracking in X-PS-Tracking header → captured in label response.
		if ( empty( $tracking_number ) && is_array( $label_result ) && ! empty( $label_result['tracking_number'] ) ) {
			$tracking_number = $label_result['tracking_number'];
			Parcselect_Label_Hooks::log( 'Tracking from VPS proxy header: ' . $tracking_number );
		}

		if ( ! $pdf || strlen( $pdf ) < 1000 ) {
			Parcselect_Label_Hooks::log( 'STEP 3 FAILED order #' . $order_id . ': label PDF not available or too small (' . ( $pdf ? strlen( $pdf ) : 0 ) . ' bytes)', 'error' );
			$order->update_meta_data( '_ps_label_tracking_number', $tracking_number );
			delete_transient( $lock_key );
			self::set_error( $order, 'Label PDF download failed — empty or corrupted response from label service' );
			return false;
		}

		Parcselect_Label_Hooks::log( 'STEP 3 OK order #' . $order_id . ': PDF downloaded (' . strlen( $pdf ) . ' bytes)' );

		// STEP 3b: Fetch tracking number from package details (via VPS proxy).
		// shipping provider often returns tracking AFTER ordering, not during creation.
		if ( empty( $tracking_number ) ) {
			$pkg_details = Parcselect_Label_API::get_package( $package_id );
			if ( $pkg_details && is_array( $pkg_details ) ) {
				$_parcels = isset( $pkg_details['parcels'] ) ? $pkg_details['parcels'] : array();
				if ( ! empty( $_parcels ) ) {
					$tracking_number = isset( $_parcels[0]['package_no'] ) ? $_parcels[0]['package_no'] : '';
					if ( empty( $tracking_number ) ) {
						$tracking_number = isset( $_parcels[0]['tracking_number'] ) ? $_parcels[0]['tracking_number'] : '';
					}
				}
				// Fallback: check top-level tracking_number.
				if ( empty( $tracking_number ) && isset( $pkg_details['tracking_number'] ) ) {
					$tracking_number = $pkg_details['tracking_number'];
				}
			}
			Parcselect_Label_Hooks::log( 'STEP 3b order #' . $order_id . ': tracking from GET /packages = ' . ( $tracking_number ? $tracking_number : '(still empty)' ) );
		}

		// STEP 4: Save PDF to labels directory.
		$labels_dir = parcselect_label_storage_dir();
		$md5        = md5( $order_id . wp_generate_uuid4() . time() );
		$filename   = $order->get_order_number() . '_' . $md5 . '.pdf';
		$filepath   = $labels_dir . $filename;

		global $wp_filesystem;
		if ( empty( $wp_filesystem ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}
		$written = $wp_filesystem->put_contents( $filepath, $pdf );

		if ( false === $written ) {
			Parcselect_Label_Hooks::log( 'STEP 4 FAILED order #' . $order_id . ': wp_filesystem->put_contents failed', 'error' );
			delete_transient( $lock_key );
			self::set_error( $order, 'Label PDF save failed — disk write error' );
			return false;
		}

		// STEP 5: Save everything to order meta.
		$order->update_meta_data( '_ps_label_tracking_number', $tracking_number );
		$order->update_meta_data( '_ps_label_label_file', $filename );
		$order->update_meta_data( '_ps_label_status', 'completed' );
		$order->update_meta_data( '_ps_label_completed_at', current_time( 'mysql' ) );
		$order->delete_meta_data( '_ps_label_error' );
		$order->save();
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}

		/* translators: 1: tracking number, 2: parcel size */
		$note = sprintf(
			__( 'Shipping label created. Tracking: %1$s | Size: %2$s', 'parcel-selector' ),
			$tracking_number,
			$size
		);
		$order->add_order_note( $note );

		Parcselect_Label_Hooks::log( 'COMPLETED order #' . $order_id . ': tracking=' . $tracking_number . ', file=' . $filename );

		// STEP 6: Send email with label PDF + packing list.
		Parcselect_Label_Email::send_label_email( $order_id );

		// STEP 7: Auto-complete order (if status is "processing").
		Parcselect_Label_Hooks::auto_complete_order( $order_id );

		// Release generation lock.
		delete_transient( $lock_key );

		return true;
	}

	/**
	 * Set error state on order.
	 *
	 * Humanizes the error message before saving so users never see raw JSON/Python dicts.
	 *
	 * @param WC_Order $order     Order object.
	 * @param string   $message   Error message (may be raw API response).
	 */
	private static function set_error( $order, $message ) {
		$order->update_meta_data( '_ps_label_status', 'error' );
		$order->update_meta_data( '_ps_label_error', sanitize_text_field( self::humanize_error( $message ) ) );
		$order->save();
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::stamp( $order );
		}
	}

	/**
	 * Convert raw label API error into human-readable message.
	 *
	 * Handles:
	 * - Python-style list of dicts: [{'path': '/pickup/name', 'message': 'Prosze podac...'}]
	 * - JSON object: {"error": "...", "errors": [...]}
	 * - Plain string — returned as-is.
	 *
	 * @param string $raw_error Raw error string from API.
	 * @return string Human-readable error message.
	 */
	public static function humanize_error( $raw_error ) {
		if ( empty( $raw_error ) ) {
			return $raw_error;
		}

		// Path-to-label map for common shipping provider validation fields.
		$labels = array(
			'/pickup/name'      => __( 'Sender name', 'parcel-selector' ),
			'/pickup/street'    => __( 'Sender street address', 'parcel-selector' ),
			'/pickup/postcode'  => __( 'Sender post code', 'parcel-selector' ),
			'/pickup/city'      => __( 'Sender city', 'parcel-selector' ),
			'/pickup/phone'     => __( 'Sender phone', 'parcel-selector' ),
			'/pickup/email'     => __( 'Sender email', 'parcel-selector' ),
			'/pickup/company'   => __( 'Sender company', 'parcel-selector' ),
			'/receiver/name'    => __( 'Receiver name', 'parcel-selector' ),
			'/receiver/street'  => __( 'Receiver street address', 'parcel-selector' ),
			'/receiver/postcode' => __( 'Receiver post code', 'parcel-selector' ),
			'/receiver/city'    => __( 'Receiver city', 'parcel-selector' ),
			'/receiver/phone'   => __( 'Receiver phone', 'parcel-selector' ),
			'/receiver/email'   => __( 'Receiver email', 'parcel-selector' ),
			'/receiver/point'   => __( 'Receiver locker/point', 'parcel-selector' ),
			'/parcels/0/weight' => __( 'Parcel weight', 'parcel-selector' ),
			'/parcels/0/width'  => __( 'Parcel width', 'parcel-selector' ),
			'/parcels/0/height' => __( 'Parcel height', 'parcel-selector' ),
			'/parcels/0/depth'  => __( 'Parcel depth', 'parcel-selector' ),
			'/service_id'       => __( 'Service ID', 'parcel-selector' ),
		);

		$input = trim( $raw_error );

		// --- Strategy 1: JSON {"error": "..."} or {"errors": [...]} format ---
		$decoded = json_decode( $input, true );
		if ( is_array( $decoded ) ) {
			// {"error": "some message"}
			if ( isset( $decoded['error'] ) && is_string( $decoded['error'] ) ) {
				$inner = $decoded['error'];
				// The error value itself may contain Python-style dicts — recurse.
				return self::humanize_error( $inner );
			}
			// {"errors": [{"path": "...", "message": "..."}]}
			if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) ) {
				$parts = array();
				foreach ( $decoded['errors'] as $err ) {
					if ( is_array( $err ) && isset( $err['message'] ) ) {
						$path  = isset( $err['path'] ) ? $err['path'] : '';
						$label = isset( $labels[ $path ] ) ? $labels[ $path ] : '';
						$msg   = $err['message'];
						$parts[] = $label ? $label . ': ' . $msg : $msg;
					}
				}
				if ( ! empty( $parts ) ) {
					return implode( ' | ', $parts );
				}
			}
			// JSON array of {"path": "...", "message": "..."} objects (no wrapper key).
			if ( isset( $decoded[0] ) && is_array( $decoded[0] ) && isset( $decoded[0]['message'] ) ) {
				$parts = array();
				foreach ( $decoded as $err ) {
					if ( ! is_array( $err ) || ! isset( $err['message'] ) ) {
						continue;
					}
					$path  = isset( $err['path'] ) ? $err['path'] : '';
					$label = isset( $labels[ $path ] ) ? $labels[ $path ] : '';
					$msg   = $err['message'];
					$parts[] = $label ? $label . ': ' . $msg : $msg;
				}
				if ( ! empty( $parts ) ) {
					return implode( ' | ', $parts );
				}
			}
		}

		// --- Strategy 2: Python-style list of dicts with single quotes ---
		// e.g. [{'path': '/pickup/name', 'message': 'Prosze podac imie i nazwisko'}]
		if ( preg_match_all( "/'path':\s*'([^']*)',\s*'message':\s*'([^']*)'/", $input, $matches, PREG_SET_ORDER ) ) {
			$parts = array();
			foreach ( $matches as $m ) {
				$path  = $m[1];
				$label = isset( $labels[ $path ] ) ? $labels[ $path ] : '';
				$msg   = $m[2];
				$parts[] = $label ? $label . ': ' . $msg : $msg;
			}
			if ( ! empty( $parts ) ) {
				return implode( ' | ', $parts );
			}
		}

		// --- Strategy 3: Python-style but only 'message' keys (no path) ---
		if ( preg_match_all( "/'message':\s*'([^']+)'/", $input, $matches ) ) {
			return implode( ' | ', $matches[1] );
		}

		// --- Fallback: return original string ---
		return $raw_error;
	}

	/**
	 * AJAX: Download label PDF securely.
	 */
	public static function download_label() {
		$order_id = absint( wp_unslash( $_GET['order_id'] ?? 0 ) );
		$nonce    = sanitize_text_field( wp_unslash( $_GET['nonce'] ?? '' ) );

		if ( ! wp_verify_nonce( $nonce, 'parcselect_label_download_' . $order_id ) ) {
			wp_die( esc_html__( 'Invalid security token.', 'parcel-selector' ) );
		}

		if ( ! current_user_can( 'edit_shop_orders' ) ) {
			wp_die( esc_html__( 'Unauthorized.', 'parcel-selector' ) );
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			wp_die( esc_html__( 'Order not found.', 'parcel-selector' ) );
		}

		// v4.1.5 B-1 (2026-04-25): if _ps_label_url meta is a full URL (CEB tokenized endpoint),
		// redirect directly to PDF instead of running broken local/VPS resolver that returns SYF.
		// v4.4.5 audyt #3 (2026-05-18): blok przeniesiony PO walidacji nonce + capability
		// (IDOR: wcześniej dowolny zalogowany użytkownik wymuszał redirect do URL etykiety
		// dowolnego zamówienia). Redirect wykonuje się teraz dopiero dla uprawnionego
		// uzytkownika z poprawnym nonce, na zweryfikowanym obiekcie zamówienia.
		$_redirect_url = (string) $order->get_meta( '_ps_label_url', true );
		// M3: jesli _ps_label_url to URL endpointu parcselect_label_pdf, w meta
		// jest on zapisany BEZ nonce (nonce wygasa => "martwa etykieta").
		// Wstrzykujemy SWIEZY nonce tuz przed redirectem. URL zdalny (legacy
		// CEB/VPS) zostaje nietkniety.
		if ( '' !== $_redirect_url && class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			$_redirect_url = Parcselect_Label_Pdf_Store::ensure_fresh_nonce( $_redirect_url );
		}
		if ( '' !== $_redirect_url && preg_match( '#^https?://#i', $_redirect_url ) ) {
			wp_safe_redirect( $_redirect_url, 302 );
			exit;
		}

		// ── CROSS-INSTALL LEAK GUARD ─────────────────────────────────────
		// Never serve a PDF when the label meta on this order belongs to a
		// different plugin installation. A leftover _ps_label_session_id
		// from a previous install would otherwise cause the VPS fallback to
		// stream somebody else's label PDF through our admin-ajax.
		if ( class_exists( 'Parcselect_Label_Installation' ) && ! Parcselect_Label_Installation::owns( $order ) ) {
			if ( Parcselect_Label_Installation::has_any_label_meta( $order ) ) {
				Parcselect_Label_Installation::purge_order( $order );
			}
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order_id, 'download.cross_install_blocked', array(
					'reason' => 'label meta does not belong to this installation',
				) );
			}
			status_header( 403 );
			wp_die( esc_html__( 'This label does not belong to this plugin installation.', 'parcel-selector' ) );
		}

		// Three-stage resolution: local mirror → legacy plugin dir → live VPS proxy.
		// Every stage writes a `download.attempt.*` event to the trail so the
		// accordion/F12 shows HIT/MISS/ERROR per attempt — not just which one won.
		$diag = array();

		$local_path = (string) $order->get_meta( '_ps_label_local_pdf_path', true );
		$diag[]     = 'local_meta=' . ( $local_path ?: 'EMPTY' );
		$local_exists = $local_path && file_exists( $local_path );
		$local_head   = '';
		$local_bytes  = 0;
		if ( $local_exists ) {
			global $wp_filesystem;
			if ( empty( $wp_filesystem ) ) {
				require_once ABSPATH . 'wp-admin/includes/file.php';
				WP_Filesystem();
			}
			$local_full  = (string) $wp_filesystem->get_contents( $local_path );
			$local_head  = substr( $local_full, 0, 4 );
			$local_bytes = (int) @filesize( $local_path ); // phpcs:ignore
		}
		$local_valid_pdf = $local_exists && '%PDF' === $local_head && $local_bytes >= 1000;
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'download.attempt.local', array(
				'meta_path'   => $local_path ?: '<empty>',
				'file_exists' => $local_exists,
				'bytes'       => $local_bytes,
				'head_magic'  => $local_head,
				'is_pdf'      => $local_valid_pdf,
				'decision'    => $local_valid_pdf ? 'SERVE' : ( $local_exists ? 'SKIP_not_pdf' : 'SKIP_missing' ),
			) );
		}
		// PATH TRAVERSAL GUARD: ensure local_path is inside expected directories.
		// Prevents arbitrary file read if order meta is manipulated by another plugin.
		$_allowed_dirs = array( trailingslashit( wp_upload_dir()['basedir'] ), defined( 'PARCSELECT_LABEL_DIR' ) ? PARCSELECT_LABEL_DIR : '' );
		$_real_local = $local_path ? realpath( $local_path ) : false;
		$_path_safe = false;
		if ( $_real_local ) {
			foreach ( $_allowed_dirs as $_ad ) {
				if ( $_ad && strpos( $_real_local, realpath( $_ad ) ) === 0 ) { $_path_safe = true; break; }
			}
		}
		if ( $local_valid_pdf && ! $_path_safe ) {
			$local_valid_pdf = false;
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order_id, 'download.path_traversal_blocked', array( 'path' => $local_path ) );
			}
		}
		if ( $local_valid_pdf ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::info( $order_id, 'download.served_local', array(
					'path'  => $local_path,
					'bytes' => $local_bytes,
				) );
				Parcselect_Label_Debug::info( $order_id, 'download.result', array(
					'source' => 'local',
					'bytes'  => $local_bytes,
					'stages_tried' => 1,
				) );
			}
			while ( ob_get_level() ) { ob_end_clean(); }
			header( 'Content-Type: application/pdf' );
			header( 'Content-Disposition: inline; filename="label-' . absint( $order->get_order_number() ) . '.pdf"' );
			header( 'Content-Length: ' . $local_bytes );
			header( 'Cache-Control: no-cache, no-store, must-revalidate' );
			header( 'Content-Security-Policy: default-src \'none\'' );
			header( 'X-PS-Label-Source: local' );
			readfile( $local_path ); // phpcs:ignore
			exit;
		}
		$diag[] = $local_exists ? 'local_skipped=not_pdf' : 'local_skipped=missing_or_empty';

		$legacy   = basename( (string) $order->get_meta( '_ps_label_label_file', true ) );
		$legacy_path = $legacy ? ( parcselect_label_storage_dir() . $legacy ) : '';
		$legacy_exists = $legacy_path && file_exists( $legacy_path );
		$legacy_bytes  = $legacy_exists ? (int) filesize( $legacy_path ) : 0;
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order_id, 'download.attempt.legacy', array(
				'meta_file'   => $legacy ?: '<empty>',
				'file_exists' => $legacy_exists,
				'bytes'       => $legacy_bytes,
				'decision'    => $legacy_exists ? 'SERVE' : ( $legacy ? 'SKIP_missing' : 'SKIP_no_meta' ),
			) );
		}
		// PATH TRAVERSAL GUARD for legacy path too.
		$_real_legacy = $legacy_path ? realpath( $legacy_path ) : false;
		$_legacy_safe = false;
		if ( $_real_legacy ) {
			foreach ( $_allowed_dirs as $_ad ) {
				if ( $_ad && strpos( $_real_legacy, realpath( $_ad ) ) === 0 ) { $_legacy_safe = true; break; }
			}
		}
		if ( $legacy && $legacy_exists && ! $_legacy_safe ) {
			$legacy_exists = false;
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order_id, 'download.legacy_path_traversal_blocked', array( 'path' => $legacy_path ) );
			}
		}
		if ( $legacy && $legacy_exists ) {
			$diag[] = 'legacy=HIT';
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::info( $order_id, 'download.served_legacy', array( 'path' => $legacy_path, 'bytes' => $legacy_bytes ) );
				Parcselect_Label_Debug::info( $order_id, 'download.result', array( 'source' => 'legacy', 'bytes' => $legacy_bytes, 'stages_tried' => 2 ) );
			}
			while ( ob_get_level() ) { ob_end_clean(); }
			header( 'Content-Type: application/pdf' );
			header( 'Content-Disposition: inline; filename="label-' . absint( $order->get_order_number() ) . '.pdf"' );
			header( 'Content-Length: ' . $legacy_bytes );
			header( 'Cache-Control: no-cache, no-store, must-revalidate' );
			header( 'Content-Security-Policy: default-src \'none\'' );
			header( 'X-PS-Label-Source: legacy' );
			readfile( $legacy_path ); // phpcs:ignore
			exit;
		}
		$diag[] = $legacy ? 'legacy=MISS' : 'legacy_skipped=no_meta';

		// Final fallback: stream-proxy from the VPS. Force sslverify=false on
		// *.local / *.test / *.dev hosts (Local by Flywheel uses a self-signed
		// cert that chokes wp_remote_get) unless user overrides the filter.
		$label_url = (string) $order->get_meta( '_ps_label_url', true );
		$diag[]    = 'label_url_meta=' . ( $label_url ?: 'EMPTY' );
		// If the webhook never landed we still have _ps_label_session_id and can
		// reconstruct the VPS download URL ourselves — otherwise the user sees
		// "all fallbacks failed" purely because the meta is missing, not because
		// the PDF is.
		$session_id_fb = (string) $order->get_meta( '_ps_label_session_id', true );
		if ( ! $label_url && $session_id_fb ) {
			$label_url = '/embed/label/download/' . rawurlencode( $session_id_fb );
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::info( $order_id, 'download.url_reconstructed_from_session', array(
					'session_id' => substr( $session_id_fb, 0, 12 ) . '…',
					'path'       => $label_url,
				) );
			}
		}
		if ( $label_url ) {
			$label_url = self::normalize_vps_url( $label_url );
			$glue       = ( false === strpos( $label_url, '?' ) ) ? '?' : '&';
			$label_url .= $glue . 'order_id=' . absint( $order_id );
			$host       = wp_parse_url( home_url(), PHP_URL_HOST );
			$is_local_dev   = $host && ( preg_match( '/\.(local|test|dev)$/i', $host ) || in_array( $host, array( 'localhost' ), true ) );
			$sslverify      = apply_filters( 'parcselect_label_sslverify', ! $is_local_dev );
			$diag[]         = 'vps_url=' . $label_url;
			$diag[]         = 'sslverify=' . ( $sslverify ? '1' : '0' );

			$t0   = microtime( true );
			$resp = wp_remote_get( $label_url, array(
				'timeout'   => 30,
				'sslverify' => $sslverify,
				'headers'   => array( 'Accept' => 'application/pdf' ),
			) );
			$elapsed_ms = (int) ( ( microtime( true ) - $t0 ) * 1000 );
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::log_http( $order_id, 'download.attempt.vps.http', $resp, array(
					'url'        => $label_url,
					'sslverify'  => $sslverify,
					'elapsed_ms' => $elapsed_ms,
				) );
			}
			if ( is_wp_error( $resp ) ) {
				$diag[] = 'vps_err=' . $resp->get_error_message();
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::error( $order_id, 'download.attempt.vps', array(
						'url'      => $label_url,
						'decision' => 'NETWORK_ERROR',
						'error'    => $resp->get_error_message(),
					) );
				}
			} else {
				$code     = (int) wp_remote_retrieve_response_code( $resp );
				$pdf      = wp_remote_retrieve_body( $resp );
				$bytes    = strlen( $pdf );
				$head     = substr( $pdf, 0, 8 );
				$source   = (string) wp_remote_retrieve_header( $resp, 'x-ps-label-source' );
				$valid    = ( 200 === $code && $bytes >= 1000 && 0 === strpos( $pdf, '%PDF' ) );
				$diag[]   = 'vps_http=' . $code . ' bytes=' . $bytes . ' head=' . bin2hex( $head ) . ' xsrc=' . ( $source ?: 'none' );
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::info( $order_id, 'download.attempt.vps', array(
						'url'           => $label_url,
						'http_code'     => $code,
						'bytes'         => $bytes,
						'head_hex'      => bin2hex( $head ),
						'vps_x_source'  => $source ?: 'none',
						'is_pdf'        => $valid,
						'decision'      => $valid ? 'SERVE' : ( $code >= 500 ? 'FAIL_5xx' : ( 404 === $code ? 'FAIL_404' : 'FAIL_invalid' ) ),
					) );
				}
				if ( $valid ) {
					if ( class_exists( 'Parcselect_Label_Debug' ) ) {
						Parcselect_Label_Debug::info( $order_id, 'download.served_vps', array( 'bytes' => $bytes, 'url' => $label_url, 'vps_x_source' => $source ) );
						Parcselect_Label_Debug::info( $order_id, 'download.result', array(
							'source'       => 'vps',
							'vps_x_source' => $source ?: 'unknown',
							'bytes'        => $bytes,
							'stages_tried' => 3,
						) );
					}
					while ( ob_get_level() ) { ob_end_clean(); }
					header( 'Content-Type: application/pdf' );
					header( 'Content-Disposition: inline; filename="label-' . absint( $order->get_order_number() ) . '.pdf"' );
					header( 'Content-Length: ' . $bytes );
					header( 'Cache-Control: no-cache, no-store, must-revalidate' );
					header( 'Content-Security-Policy: default-src \'none\'' );
					header( 'X-PS-Label-Source: vps' );
					header( 'X-PS-VPS-Source: ' . ( $source ?: 'unknown' ) );
					echo $pdf; // phpcs:ignore
					exit;
				}
			}
		} elseif ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::warn( $order_id, 'download.attempt.vps.skipped', array( 'reason' => 'no_label_url_and_no_session_id' ) );
		}

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::error( $order_id, 'download.all_fallbacks_failed', array( 'diag' => $diag ) );
			Parcselect_Label_Debug::error( $order_id, 'download.result', array( 'source' => 'none', 'stages_tried' => 3, 'diag' => $diag ) );
		}

		status_header( 503 );
		header( 'Content-Type: text/plain; charset=utf-8' );
		header( 'X-PS-Label-Source: none' );
		echo esc_html__( 'Label PDF is not available locally or on the VPS.', 'parcel-selector' ) . "\n\n";
		echo "Diagnostic trail (see WC logger for full structured log):\n";
		foreach ( $diag as $line ) {
			echo '  - ' . esc_html( $line ) . "\n";
		}
		exit;
	}

	/**
	 * AJAX: INTERNAL_PAYMENT — manual payment confirmation for testing.
	 * Admin clicks "Confirm payment" instead of waiting for blockchain daemon.
	 * This simulates what pscp_payment_confirmed hook would do.
	 */
	/**
	 * AJAX: Test label API connection — diagnostics.
	 * Tests: OAuth token, services list, price calculation.
	 */
}

Parcselect_Label_Ajax::init();
