<?php
/**
 * Label Webhook — receives tracking data from VPS after label generation.
 *
 * REST endpoint: POST /wp-json/ps/v1/label-callback
 * Called by parcelselector.com VPS after a shipping label is generated.
 *
 * @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

/**
 * Class PS_Label_Webhook
 */
class Parcselect_Label_Webhook {

	/**
	 * Initialize REST route.
	 */
	public static function init() {
		add_action( 'rest_api_init', array( __CLASS__, 'register_route' ) );
	}

	/**
	 * Register the REST API route.
	 */
	public static function register_route() {
		register_rest_route( 'ps/v1', '/label-callback', array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'handle_callback' ),
			'permission_callback' => array( __CLASS__, 'verify_request' ),
		) );
	}

	/**
	 * Verify the incoming request has valid secret header.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return bool Whether the request is authorized.
	 */
	public static function verify_request( $request ) {
		$secret = $request->get_header( 'X-Embed-Label-Secret' );
		$reason = null;
		if ( empty( $secret ) || strlen( $secret ) < 32 ) {
			$reason = 'secret_too_short';
		} else {
			// Primary: legacy ps_label_webhook_secret from map-server enrollment.
			if ( defined( 'PS_WEBHOOK_SECRET' ) && PS_WEBHOOK_SECRET ) {
				$expected = (string) PS_WEBHOOK_SECRET;
			} else {
				$expected = (string) get_option( 'parcselect_label_webhook_secret', '' );
			}
			$primary_ok = ! empty( $expected ) && strlen( $expected ) >= 32 && hash_equals( $expected, (string) $secret );

			// Fallback: etykietownik.parcelselector.com (mu-plugin crypto-etyk-bridge)
			// signs with hash('sha256', home_url . '|etykietownik-ceb|' . salt).
			// NOTE: The mu-plugin on VPS must know this salt. Currently it is passed
			// via the iframe ceb_salt param during ceb/v1/create-order. In the future
			// the salt should be exchanged during enrollment.
			// CEB fallback uses a per-site random salt (generated on first access).
			// This prevents attackers from computing the secret from public home_url().
			$ceb_salt = get_option( 'parcselect_label_ceb_salt', '' );
			if ( empty( $ceb_salt ) ) {
				$ceb_salt = wp_generate_password( 32, false, false );
				update_option( 'parcselect_label_ceb_salt', $ceb_salt, true );
			}
			$ceb_expected = hash( 'sha256', home_url() . '|etykietownik-ceb|' . $ceb_salt );
			$ceb_ok = hash_equals( $ceb_expected, (string) $secret );

			// SEC-3 (2026-04-24): CEB crypto-etyk-bridge.php::ceb_notify_source uses
			// hash_hmac('sha256', source_site.'|'.order_id.'|etykietownik-ceb', salt).
			// Match that format too — legacy hash above remains for old-style callers.
			$ceb_new_ok = false;
			// H3 fix (2026-04-25): CEB signs HMAC with CEB-side WC order ID (sent in
			// X-CEB-Order-Id header), NOT the plugin-side order_id from body. Header
			// first, body as fallback for legacy/test flows.
			$order_id_hint = (string) $request->get_header( 'x_ceb_order_id' );
			if ( '' === $order_id_hint ) {
				$order_id_hint = (string) $request->get_param( 'order_id' );
			}
			if ( '' !== $order_id_hint ) {
				$home = home_url();
				// Try primary home URL AND case variants — CEB may use rtrim'd or cased source_site.
				foreach ( array( $home, rtrim( $home, '/' ), strtolower( $home ), rtrim( strtolower( $home ), '/' ) ) as $site_variant ) {
					$ceb_sec3_expected = hash_hmac(
						'sha256',
						$site_variant . '|' . $order_id_hint . '|etykietownik-ceb',
						$ceb_salt
					);
					if ( hash_equals( $ceb_sec3_expected, (string) $secret ) ) {
						$ceb_new_ok = true;
						break;
					}
				}
			}

			if ( ! $primary_ok && ! $ceb_ok && ! $ceb_new_ok ) {
				$reason = 'hmac_mismatch';
			}
		}
		if ( $reason && class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::warn( null, 'webhook.auth_failed', array(
				'reason'      => $reason,
				'secret_len'  => is_string( $secret ) ? strlen( $secret ) : 0,
				'remote_addr' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
			) );
		}
		return null === $reason;
	}

	/**
	 * Handle the label callback from VPS.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response Response.
	 */
	public static function handle_callback( $request ) {
		// v4.0.10 P1#5 — strict schema whitelist. Webhook body MUST contain
		// only the 8 documented keys. Any extra / unknown key = reject 400
		// and log. Prevents schema drift attacks (VPS starts shipping new
		// fields that plugin blindly trusts) and signals when something
		// upstream changes that plugin code needs to catch up to.
		$_allowed_keys = array(
			'order_id', 'tracking_number', 'carrier', 'label_url',
			'status', 'session_id', 'installation_id', 'error',
			// H11 fix (2026-04-25): Oracle-dispatched webhook extra fields.
			'pi_id', 'txid', 'sol_amount',
		);
		$_all_params   = (array) $request->get_params();
		$_extra_keys   = array_diff( array_keys( $_all_params ), $_allowed_keys );
		if ( ! empty( $_extra_keys ) ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( null, 'webhook.REJECTED_schema_drift', array(
					'reason'      => 'webhook body contains keys outside documented schema',
					'extra_keys'  => array_slice( $_extra_keys, 0, 20 ),
					'remote_addr' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
				) );
			}
			return new WP_REST_Response( array(
				'ok'    => false,
				'error' => 'schema_drift',
				'detail' => 'unknown keys: ' . implode( ',', array_slice( $_extra_keys, 0, 10 ) ),
			), 400 );
		}

		$order_id        = absint( $request->get_param( 'order_id' ) );
		$tracking_number = sanitize_text_field( $request->get_param( 'tracking_number' ) );
		$carrier         = sanitize_text_field( $request->get_param( 'carrier' ) );
		$label_url       = esc_url_raw( $request->get_param( 'label_url' ) );
		$status          = sanitize_text_field( $request->get_param( 'status' ) );
		$session_id      = sanitize_text_field( $request->get_param( 'session_id' ) );
		$error_msg       = sanitize_text_field( $request->get_param( 'error' ) );
		$resp_iid        = sanitize_text_field( $request->get_param( 'installation_id' ) );
		// DEV-4 2026-05-16: crypto-proof. Webhook status=completed niesie
		// txid + sol_amount (BUG-E payload, oba juz w $_allowed_keys powyzej).
		// Stary handler ich nie zapisywal -> metabox/embed pokazywaly puste
		// pola Solscan dopoki nie dociagnal ich pozniejszy poller order-status.
		$txid       = sanitize_text_field( $request->get_param( 'txid' ) );
		$sol_amount = sanitize_text_field( $request->get_param( 'sol_amount' ) );

		// === v4.0.5 ZERO-LEAK — webhook MUST carry our installation_id ===
		// Even with valid HMAC (which proves "VPS sent this"), we MUST
		// verify "VPS sent this TO US" — otherwise a webhook intended for
		// shop B that hits shop A by routing error / shared secret would
		// be accepted by shop A and cross-shop label data would be written.
		// REQUIRED (was OPTIONAL in v4.0.4 audit). Reject 403 with debug log.
		// B4 (2026-04-25): if webhook carries pi_id, verify it matches local meta.
		// Prevents replay of another order's webhook on this shop.
		$incoming_pi_id = isset( $_all_params['pi_id'] ) ? (string) $_all_params['pi_id'] : '';
		if ( '' !== $incoming_pi_id ) {
			$_chk_oid = isset( $_all_params['order_id'] ) ? (int) $_all_params['order_id'] : 0;
			$_chk_ord = $_chk_oid ? wc_get_order( $_chk_oid ) : null;
			if ( $_chk_ord instanceof WC_Order ) {
				$local_pi = (string) $_chk_ord->get_meta( '_ps_label_pi_id', true );
				if ( '' !== $local_pi && ! hash_equals( $local_pi, $incoming_pi_id ) ) {
					if ( class_exists( 'Parcselect_Label_Debug' ) ) {
						Parcselect_Label_Debug::error( $_chk_oid, 'webhook.pi_id_mismatch', array(
							'incoming' => substr( $incoming_pi_id, 0, 12 ),
							'local'    => substr( $local_pi, 0, 12 ),
						) );
					}
					return new WP_REST_Response( array( 'error' => 'pi_id_mismatch' ), 403 );
				}
			}
		}

		// 2026-05-15 ROOT-CAUSE FIX: webhook accepts BOTH installation_id formats.
		// (1) raw UUID = parcselect_installation_id option (plugin-generated UUID v4)
		// (2) identity_marker = "v2:" . wp_hash(uuid|home|siteurl) — composite hash
		//
		// Prior bug: CEB notify_source posyla wartosc z order meta _ps_label_installation_id,
		// ktore plugin stamps as identity_marker (v2:hash) — NIE raw UUID. Plugin webhook
		// porownywal z option (raw UUID) — zawsze MISMATCH -> 403. Wszystkie paid orders
		// 2026-05-14 stracily etykiety przez ten gate (#90, #301).
		$our_iid    = (string) get_option( 'parcselect_installation_id', '' );
		$our_marker = class_exists( 'Parcselect_Label_Installation' ) ? Parcselect_Label_Installation::identity_marker() : '';
		// BUG-4b 2026-05-15: CEB notify_source wysyla installation_id = _pscp_oracle_merchant
		// (Oracle merchant UUID), ktory plugin zapisuje przy /api/v1/enroll w opcji
		// parcselect_label_merchant_id. To TRZECI prawidlowy token tozsamosci obok
		// raw UUID (parcselect_installation_id) i v2:identity_marker. Bez tego KAZDY
		// crypto order dostaje webhook 403 installation_id_mismatch (order #98, #57/#58).
		$our_merchant = (string) get_option( 'parcselect_label_merchant_id', '' );
		$iid_match  = false;
		if ( '' !== $resp_iid ) {
			if ( '' !== $our_iid      && hash_equals( $our_iid,      $resp_iid ) ) $iid_match = true;
			if ( '' !== $our_marker   && hash_equals( $our_marker,   $resp_iid ) ) $iid_match = true;
			if ( '' !== $our_merchant && hash_equals( $our_merchant, $resp_iid ) ) $iid_match = true;
		}
		if ( ! $iid_match ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order_id ?: null, 'webhook.REJECTED_installation_id_mismatch', array(
					'reason'              => 'webhook body did not match installation_id NOR identity_marker',
					'our_iid_present'     => '' !== $our_iid,
					'our_marker_present'  => '' !== $our_marker,
					'resp_iid_present'    => '' !== $resp_iid,
					'resp_iid_preview'    => '' !== $resp_iid ? substr( $resp_iid, 0, 24 ) : '',
					'remote_addr'         => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
				) );
			}
			return new WP_REST_Response( array(
				'ok'    => false,
				'error' => 'installation_id_mismatch',
				'detail' => 'Webhook body must include installation_id matching this site\'s enrolled UUID, v2:identity_marker, or Oracle merchant_id.',
			), 403 );
		}

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			// Raw inbound payload — log EVERYTHING VPS sent so a schema drift
			// (key renamed, new field added, missing field) surfaces in the trail.
			$raw_body        = (string) $request->get_body();
			$all_params      = $request->get_params();
			$hdr_names       = array( 'content-type', 'content-length', 'user-agent', 'x-embed-label-secret', 'x-ps-request-id', 'x-forwarded-for' );
			$hdrs_present    = array();
			foreach ( $hdr_names as $h ) {
				$v = $request->get_header( $h );
				if ( null !== $v && '' !== $v ) {
					// mask the secret header, keep only length
					$hdrs_present[ $h ] = ( 'x-embed-label-secret' === $h ) ? 'len=' . strlen( $v ) : $v;
				}
			}
			Parcselect_Label_Debug::info( $order_id ?: null, 'webhook.raw', array(
				'remote_addr' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
				'body_bytes'  => strlen( $raw_body ),
				'body_head'   => substr( $raw_body, 0, 300 ),
				'param_keys'  => array_slice( array_keys( $all_params ), 0, 20 ),
				'headers'     => $hdrs_present,
			) );
			Parcselect_Label_Debug::info( $order_id ?: null, 'webhook.received', array(
				'status'     => $status,
				'carrier'    => $carrier,
				'session_id' => $session_id,
				'has_url'    => ! empty( $label_url ),
				'has_track'  => ! empty( $tracking_number ),
				'has_error'  => ! empty( $error_msg ),
			) );
			// Format validation for the webhook body — completed webhooks MUST
			// carry order_id + session_id + tracking_number + label_url or we
			// end up with a "completed" status and empty tracking.
			if ( 'completed' === $status ) {
				Parcselect_Label_Debug::validate_format(
					$order_id ?: null,
					'webhook.completed',
					$all_params,
					array( 'order_id', 'session_id', 'tracking_number', 'label_url', 'status' ),
					array( 'carrier', 'installation_id' )
				);
			} elseif ( 'error' === $status ) {
				Parcselect_Label_Debug::validate_format(
					$order_id ?: null,
					'webhook.error',
					$all_params,
					array( 'order_id', 'session_id', 'status', 'error' ),
					array( 'carrier' )
				);
			}
		}

		// Handle error status separately — VPS notifies us when shipping provider/payment
		// generation fails. We surface it as an order note + meta so merchant sees
		// the reason in wp-admin without opening the iframe. No tracking yet.
		if ( 'error' === $status && $order_id ) {
			$order = wc_get_order( $order_id );
			if ( $order ) {
				$order->update_meta_data( '_ps_label_status', 'error' );
				$order->update_meta_data( '_ps_label_error', $error_msg );
				$order->update_meta_data( '_ps_label_session_id', $session_id );
				$order->save();
				if ( class_exists( 'Parcselect_Label_Installation' ) ) {
					Parcselect_Label_Installation::stamp( $order );
				}
				$order->add_order_note( sprintf(
					/* translators: %s: error message from VPS */
					esc_html__( 'Label generation failed: %s — merchant can retry from the Label tool metabox.', 'parcel-selector' ),
					$error_msg ?: 'unknown error'
				) );
			}
			return new WP_REST_Response( array( 'ok' => true, 'recorded' => 'error' ), 200 );
		}

		if ( ! $order_id || ! $tracking_number ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::warn( $order_id ?: null, 'webhook.missing_params', array(
					'order_id_present' => (bool) $order_id,
					'tracking_present' => ! empty( $tracking_number ),
				) );
			}
			return new WP_REST_Response( array( 'ok' => false, 'error' => 'Missing order_id or tracking_number' ), 400 );
		}

		// v4.0.10 P0#6 — strict format validation of tracking_number.
		// FREE = InPost Paczkomaty (17-digit tracking format). Reject anything
		// that does not match — blocks arbitrary text injection via webhook and
		// catches the canonical leaked value "5200000150214001067-31517"
		// (mixed separator) that the central etykietownik was propagating.
		$_tracking_clean = preg_replace( '/[\s\-_]+/', '', (string) $tracking_number );
		$_tracking_valid = (bool) preg_match( '/^[A-Z0-9]{8,30}$/i', $_tracking_clean );
		if ( ! $_tracking_valid ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order_id, 'webhook.REJECTED_bad_tracking_format', array(
					'reason'           => 'tracking_number does not match any supported carrier format',
					'received_length'  => strlen( (string) $tracking_number ),
					'received_preview' => substr( (string) $tracking_number, 0, 24 ),
				) );
			}
			return new WP_REST_Response( array(
				'ok'    => false,
				'error' => 'invalid_tracking_format',
			), 400 );
		}
		// Use the cleaned form from here on — no stray separators in DB.
		$tracking_number = $_tracking_clean;

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( null, 'webhook.order_not_found', array( 'order_id' => $order_id ) );
			}
			return new WP_REST_Response( array( 'ok' => false, 'error' => 'Order not found' ), 404 );
		}

		// QW#3 — idempotency lock. If the same session_id lands again within
		// 5 minutes, return OK immediately without re-updating meta, re-adding
		// an order note, or re-triggering any JS-visible state transition.
		// Happens when VPS retries the webhook or our own polling concurrently
		// detected the completion first.
		if ( ! empty( $session_id ) ) {
			$lock_key = 'parcselect_label_wh_' . md5( $session_id );
			if ( get_transient( $lock_key ) ) {
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::info( $order_id, 'webhook.duplicate_ignored', array( 'session_id' => $session_id ) );
				}
				return new WP_REST_Response( array( 'ok' => true, 'duplicate' => true ), 200 );
			}
			set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS );
		}

		// Canonical key only. The bare `_ps_tracking_number` alias was
		// dropped in v4.0.15 because `Parcselect_Label_Installation::purge_order()`
		// sweeps only `_ps_label_*` at runtime, so the alias survived
		// cross-install cleanups and resurfaced leaked tracking numbers on
		// the orders list. Consumers must use `_ps_label_tracking_number`;
		// the guard's `legacy_alias_keys()` still wipes any residue.
		$order->update_meta_data( '_ps_label_tracking_number', $tracking_number );
		$order->update_meta_data( '_ps_label_carrier', $carrier );
		$order->update_meta_data( '_ps_label_url', $label_url );
		$order->update_meta_data( '_ps_label_session_id', $session_id );
		$order->update_meta_data( '_ps_label_status', $status );
		$order->update_meta_data( '_ps_label_completed_at', current_time( 'mysql' ) );
		// DEV-4 2026-05-16: utrwal crypto-proof jesli webhook go niesie.
		// Warunek '' !== — webhook bez txid (error-path / starszy CEB) NIE
		// nadpisuje wartosci dociagnietej wczesniej inna sciezka. Addytywne,
		// txid dla danego orderu jest niezmienny -> zapis jest idempotentny.
		if ( '' !== $txid ) {
			$order->update_meta_data( '_ps_label_txid', $txid );
		}
		if ( '' !== $sol_amount ) {
			$order->update_meta_data( '_ps_label_sol_amount', $sol_amount );
		}
		$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, 'webhook.order_updated', array(
				'tracking_number' => $tracking_number,
				'status'          => $status,
				'carrier'         => $carrier,
			) );
		}

		// Add order note.
		/* translators: 1: tracking number, 2: carrier name */
		$note = sprintf(
			esc_html__( 'Shipping label generated. Tracking: %1$s (%2$s)', 'parcel-selector' ),
			$tracking_number,
			$carrier
		);
		$order->add_order_note( $note );

		// Mirror the PDF locally so the merchant keeps a copy after the VPS
		// Redis TTL (7 days) expires. Best-effort — a failure here never breaks
		// the webhook response. The remote URL remains the primary source until
		// the mirror succeeds.
		if ( ! empty( $label_url ) ) {
			// v4.2 B.3 SPEC/10 sekcja 11 — Pdf_Store fetch + zamiana _ps_label_url na LOKALNY URL
			// Klient nigdy nie widzi naszego hostname — iframe src wskazuje /wp-content/uploads/etykietownik/...
			// v4.2.1 BEZWZGLEDNY consent gate — Pdf_Store fetch wylacznie po Stage 1 consent
			if ( class_exists( 'Parcselect_Label_Consent_Gate' ) && ! Parcselect_Label_Consent_Gate::is_valid() ) {
				if ( class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::warn( $order->get_id(), 'webhook.pdf_store.skipped_no_consent', array() );
				}
			} elseif ( class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
				$_pdf_local = Parcselect_Label_Pdf_Store::fetch_and_store( $order, $label_url, $tracking_number );
				if ( ! is_wp_error( $_pdf_local ) && class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::info( $order->get_id(), 'pdf_store.webhook.ok', array( 'local_url' => $_pdf_local ) );
				} elseif ( is_wp_error( $_pdf_local ) && class_exists( 'Parcselect_Label_Debug' ) ) {
					Parcselect_Label_Debug::warn( $order->get_id(), 'pdf_store.webhook.fail', array(
						'code' => $_pdf_local->get_error_code(),
						'msg'  => $_pdf_local->get_error_message(),
					) );
				}
			}
			// v4.4.5 M3 §3.4 — legacy mirror do uploads/ps-labels/ WYŁĄCZONY.
			// PDF żyje teraz wyłącznie w tabeli DB (Parcselect_Label_Pdf_Store).
			// Publiczny plik = luka bezpieczeństwa (blokada WP.org).
			// self::mirror_pdf_locally( $order, $label_url );
		}

		return new WP_REST_Response( array( 'ok' => true ), 200 );
	}

	/**
	 * Download the PDF from the VPS download endpoint and save it to
	 * uploads/ps-labels/. Writes the resulting absolute path to
	 * _ps_label_local_pdf_path and the public-relative URL to
	 * _ps_label_local_pdf_url. Intentionally serves from a non-guessable
	 * filename containing a random token so direct URL guessing is hard
	 * even if the uploads dir happens to be listable.
	 *
	 * @param WC_Order $order     Order object.
	 * @param string   $label_url Either an absolute URL or path like /embed/label/download/<id>.
	 */
	protected static function mirror_pdf_locally( $order, $label_url ) {
		// SSRF guard: only allow PDF downloads from trusted domains.
		$_allowed_hosts = array( 'map.parcelselector.com', 'parcelselector.com', 'etykietownik.parcelselector.com' );
		$_allowed_hosts = apply_filters( 'parcselect_label_mirror_allowed_hosts', $_allowed_hosts );
		$_parsed        = wp_parse_url( $label_url );
		$_host          = isset( $_parsed['host'] ) ? strtolower( $_parsed['host'] ) : '';
		if ( ! in_array( $_host, $_allowed_hosts, true ) ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::error( $order->get_id(), 'pdf.mirror.ssrf_blocked', array( 'url' => $label_url, 'host' => $_host ) );
			}
			return;
		}

		$absolute = class_exists( 'Parcselect_Label_Ajax' ) && method_exists( 'Parcselect_Label_Ajax', 'normalize_vps_url' )
			? Parcselect_Label_Ajax::normalize_vps_url( $label_url )
			: $label_url;
		// BUG-T 2026-05-15: URL z tokenem (CEB label-pdf) self-contained — nie
		// dopisuj order_id (CEB zwraca 404 na obce order_id).
		if ( false === strpos( $absolute, 'token=' ) ) {
			$glue      = ( false === strpos( $absolute, '?' ) ) ? '?' : '&';
			$absolute .= $glue . 'order_id=' . absint( $order->get_id() );
		}

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order->get_id(), 'pdf.mirror.request', array( 'url' => $absolute ) );
		}
		$t0   = microtime( true );
		$resp = wp_remote_get( $absolute, array(
			'timeout'   => 45,
			'sslverify' => apply_filters( 'parcselect_label_sslverify', true ),
		) );
		$elapsed_ms = (int) ( ( microtime( true ) - $t0 ) * 1000 );
		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::log_http( $order->get_id(), 'pdf.mirror.http', $resp, array(
				'elapsed_ms' => $elapsed_ms,
				'url'        => $absolute,
			) );
		}
		if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::warn( $order->get_id(), 'pdf.mirror_failed', array(
					'url'   => $absolute,
					'error' => is_wp_error( $resp ) ? $resp->get_error_message() : wp_remote_retrieve_response_code( $resp ),
				) );
			}
			return;
		}
		$pdf    = wp_remote_retrieve_body( $resp );
		$source = (string) wp_remote_retrieve_header( $resp, 'x-ps-label-source' );
		if ( strlen( $pdf ) < 1000 || 0 !== strpos( $pdf, '%PDF' ) ) {
			if ( class_exists( 'Parcselect_Label_Debug' ) ) {
				Parcselect_Label_Debug::warn( $order->get_id(), 'pdf.mirror_invalid', array(
					'bytes'         => strlen( $pdf ),
					'header'        => substr( $pdf, 0, 10 ),
					'vps_x_source'  => $source,
				) );
			}
			return;
		}

		$uploads = wp_upload_dir();
		$dir     = trailingslashit( $uploads['basedir'] ) . 'ps-labels';
		if ( ! file_exists( $dir ) ) {
			wp_mkdir_p( $dir );
			// Lock down the directory from directory listing.
			global $wp_filesystem;
			if ( empty( $wp_filesystem ) ) {
				require_once ABSPATH . 'wp-admin/includes/file.php';
				WP_Filesystem();
			}
			if ( $wp_filesystem ) {
				// Block directory listing on both Apache and Nginx.
				$wp_filesystem->put_contents( $dir . '/index.php', "<?php\nhttp_response_code(403);\nexit('Access denied.');\n", FS_CHMOD_FILE );
				$wp_filesystem->put_contents( $dir . '/.htaccess', "Options -Indexes\nDeny from all\n", FS_CHMOD_FILE );
			}
		}

		$token    = wp_generate_password( 12, false, false );
		$filename = sprintf( 'order-%d-%s.pdf', $order->get_id(), $token );
		$path     = $dir . '/' . $filename;
		global $wp_filesystem;
		if ( empty( $wp_filesystem ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}
		if ( false === $wp_filesystem->put_contents( $path, $pdf ) ) {
			return;
		}

		// Store path only — never expose direct public URL.
		// PDF is served exclusively through the authenticated AJAX download handler
		// (parcselect_label_download_label) which checks edit_shop_orders capability.
		$order->update_meta_data( '_ps_label_local_pdf_path', $path );
		$_download_url = admin_url( 'admin-ajax.php?action=parcselect_label_download_label&order_id=' . $order->get_id() . '&nonce=' . wp_create_nonce( 'parcselect_label_download_' . $order->get_id() ) );
		$order->update_meta_data( '_ps_label_local_pdf_url', $_download_url );
		$order->save();

		if ( class_exists( 'Parcselect_Label_Debug' ) ) {
			Parcselect_Label_Debug::info( $order->get_id(), 'pdf.mirrored', array(
				'bytes'         => strlen( $pdf ),
				'path'          => $path,
				'download_url'  => $_download_url,
				'vps_x_source'  => $source,
			) );
		}
	}
}
