<?php
/**
 * ETYKIETOWNIK meta box — inline UI in WooCommerce order admin page.
 *
 * Readiness checklist → switch → options → QR payment → label download.
 * Admin ALWAYS knows if a label can be created before clicking anything.
 * No popups. No page navigation. Everything in one place.
 *
 * @package ParcelSelector
 * @since   3.4.0
 */

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

class Parcselect_Label_Metabox {

	/**
	 * Carrier display names — single source of truth within Etykietownik.
	 *
	 * @return array slug => display name
	 */
	public static function carrier_names() {
		return array(
			'inpost' => 'InPost',
		);
	}

	public static function init() {
		// Priority 5 — register BEFORE WC order data box (default priority 10).
		add_action( 'add_meta_boxes', array( __CLASS__, 'register' ), 5 );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
		// JS reorder: move Etykietownik right after order_data (shipping details).
		add_action( 'admin_footer', array( __CLASS__, 'reorder_metabox_js' ) );
	}

	/**
	 * Register meta box on order edit screens.
	 */
	/** @var bool Prevents double registration on HPOS screens. */
	private static $registered = false;

	public static function register() {
		// Static guard — register exactly once per request, no matter how many times hook fires.
		if ( self::$registered ) {
			return;
		}

		$screen = get_current_screen();
		$target = null;
		if ( $screen ) {
			if ( 'woocommerce_page_wc-orders' === $screen->id ) {
				$target = 'woocommerce_page_wc-orders';
			} elseif ( 'shop_order' === $screen->id ) {
				$target = 'shop_order';
			}
		}
		if ( ! $target ) {
			return;
		}

		$_etyk_title = esc_html__( 'Label tool', 'parcel-selector' ) . ' <span class="etyk-title-cta" style="font-weight:400;color:#64748b;font-size:12px;">'
			. esc_html__( 'Shipping label creator', 'parcel-selector' ) . '</span>';
		add_meta_box(
			'parcselect_label_box',
			$_etyk_title,
			array( __CLASS__, 'render' ),
			$target,
			'normal',
			'high'
		);

		self::$registered = true;
	}

	/**
	 * JS: Move Etykietownik metabox right after WC order data (shipping details).
	 * User workflow: see customer data → prepare label → then check order items.
	 */
	public static function reorder_metabox_js() {
		$screen = get_current_screen();
		if ( ! $screen ) {
			return;
		}
		$order_screens = array( 'shop_order', 'woocommerce_page_wc-orders' );
		if ( ! in_array( $screen->id, $order_screens, true ) ) {
			return;
		}
		wp_register_script( 'parcselect-label-reorder', '', array( 'jquery' ), PARCSELECT_VERSION, true );
		wp_enqueue_script( 'parcselect-label-reorder' );
		wp_add_inline_script( 'parcselect-label-reorder', 'jQuery(function($) {
			var $all = $("[id=\"ps_label_box\"]");
			if ($all.length > 1) { $all.not(":first").remove(); }
			var $etyk = $("#ps_label_box");
			if (!$etyk.length) return;
			var $orderData = $("#woocommerce-order-data").first();
			if ($orderData.length) { $etyk.detach().insertAfter($orderData); }
		});' );
		?><?php
	}

	/**
	 * Run all readiness checks for a given order.
	 * Returns structured array: each check has ok, label, value, fix (if failed).
	 *
	 * @param WC_Order $order Order object.
	 * @return array [ 'ready' => bool, 'checks' => [...], 'data' => [...] ]
	 */
	public static function check_readiness( $order ) {
		$checks = array();
		$data   = array();

		// ── ALL DATA PULLED AUTOMATICALLY — ZERO ADMIN INPUT NEEDED ──

		// 1. Pickup point (from ParcelSelector checkout).
		$locker = $order->get_meta( '_ps_locker_name', true );
		$city   = $order->get_meta( '_ps_locker_city', true );
		$checks['locker'] = array(
			'ok'    => ! empty( $locker ),
			'label' => __( 'Pickup point', 'parcel-selector' ),
			'value' => $locker ? $locker . ( $city ? ' (' . $city . ')' : '' ) : '',
			'fix'   => __( 'Customer did not select a pickup point.', 'parcel-selector' ),
		);
		$data['locker'] = $locker;

		// 2. Country (from order).
		$country     = strtoupper( preg_replace( '/[^a-zA-Z]/', '', substr( (string) $order->get_meta( '_ps_locker_country', true ), 0, 2 ) ) );
		$config      = null;
		if ( preg_match( '/^[A-Z]{2}$/', $country ) ) {
			$config_file = PARCSELECT_LABEL_DIR . 'config/' . $country . '.php';
			$config      = file_exists( $config_file ) ? include $config_file : null;
		}
		$country_ok  = $config && ! empty( $config['enabled'] );
		$_country_flag = self::country_flag( strtoupper( $country ) );
		$checks['country'] = array(
			'ok'    => $country_ok,
			'label' => __( 'Country', 'parcel-selector' ),
			'value' => $country_ok ? $_country_flag . ' ' . $config['country_name'] : $_country_flag . ' ' . strtoupper( $country ),
			'fix'   => __( 'Not supported yet.', 'parcel-selector' ),
		);
		$data['country'] = $country;

		// 3. Receiver (from order — customer filled this at checkout).
		$recv_name  = trim( $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name() );
		if ( empty( $recv_name ) ) {
			$recv_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
		}
		$recv_phone = $order->get_billing_phone();
		$recv_email = $order->get_billing_email();

		$recv_ok = ! empty( $recv_name ) && ! empty( $recv_phone ) && ! empty( $recv_email );
		$checks['receiver'] = array(
			'ok'    => $recv_ok,
			'label' => __( 'Receiver', 'parcel-selector' ),
			'value' => $recv_ok ? $recv_name . ' | ' . $recv_phone : '',
			'fix'   => empty( $recv_name ) ? __( 'No name on order.', 'parcel-selector' ) : ( empty( $recv_phone ) ? __( 'No phone on order.', 'parcel-selector' ) : __( 'No email on order.', 'parcel-selector' ) ),
		);

		// 3b. Receiver email (required by carrier for shipment notifications).
		$checks['email'] = array(
			'ok'    => ! empty( $recv_email ),
			'label' => __( 'Email', 'parcel-selector' ),
			'value' => ! empty( $recv_email ) ? $recv_email : '',
			'fix'   => __( 'Customer email missing — required for shipping notifications.', 'parcel-selector' ),
		);

		// 3c. Carrier detection.
		$carrier = 'inpost'; // default
		if ( class_exists( 'Parcselect_Label_Carrier' ) ) {
			$carrier = Parcselect_Label_Carrier::detect( $order );
		}
		$carrier_names = self::carrier_names();
		$carrier_label = isset( $carrier_names[ $carrier ] ) ? $carrier_names[ $carrier ] : ucfirst( $carrier );
		$checks['carrier'] = array(
			'ok'    => true,
			'label' => __( 'Carrier', 'parcel-selector' ),
			'value' => $carrier_label,
			'fix'   => '',
		);
		$data['carrier'] = $carrier;

		// 4. Sender — shipping provider ALWAYS requires full sender address (even for parcel lockers).
		// Fallbacks: etykietownik settings → WooCommerce store settings.
		$_etyk_settings_url = admin_url( 'admin.php?page=parcelselector-settings&tab=etykietownik' );
		$_etyk_sender_url   = $_etyk_settings_url . '#etyk-sender';
		$_etyk_dropoff_url  = $_etyk_settings_url . '#etyk-dropoff';

		$sender_name  = get_option( 'parcselect_label_sender_name', '' ) ?: get_option( 'blogname', '' );
		$sender_phone = get_option( 'parcselect_label_sender_phone', '' );
		$sender_street = get_option( 'parcselect_label_sender_street', '' ) ?: get_option( 'woocommerce_store_address', '' );
		$sender_city  = get_option( 'parcselect_label_sender_city', '' ) ?: get_option( 'woocommerce_store_city', '' );

		$sender_errors = array();
		if ( empty( $sender_name ) ) {
			$sender_errors[] = __( 'name', 'parcel-selector' );
		}
		if ( empty( $sender_street ) ) {
			$sender_errors[] = __( 'street', 'parcel-selector' );
		}
		if ( empty( $sender_city ) ) {
			$sender_errors[] = __( 'city', 'parcel-selector' );
		}
		$sender_ok = empty( $sender_errors );
		$checks['sender'] = array(
			'ok'    => $sender_ok,
			'label' => __( 'Sender', 'parcel-selector' ),
			'value' => $sender_ok ? $sender_name . ', ' . $sender_city : '',
			'fix'   => $sender_ok ? '' :
				/* translators: 1: list of missing fields, 2: opening link tag, 3: closing link tag */
				sprintf( __( 'Missing sender %1$s. %2$sConfigure sender settings →%3$s', 'parcel-selector' ), implode( ', ', $sender_errors ), '<a href="' . esc_url( $_etyk_sender_url ) . '" target="_blank">', '</a>' ),
		);

		// 4b. Drop-off point — InPost Paczkomaty sender drop-off location (mandatory).
		if ( 'inpost' === $carrier ) {
			$_pickup_val = get_option( 'parcselect_label_pickup_' . $carrier, '' );
			$_is_mandatory = true;
			$checks['dropoff'] = array(
				'ok'    => ! empty( $_pickup_val ) || ! $_is_mandatory,
				'label' => __( 'Drop-off point', 'parcel-selector' ),
				'value' => ! empty( $_pickup_val ) ? strtoupper( $_pickup_val ) . ' (' . $carrier_label . ')' : ( $_is_mandatory ? '' : __( 'Optional', 'parcel-selector' ) ),
				'fix'   => empty( $_pickup_val ) && $_is_mandatory ?
					/* translators: 1: carrier name, 2: opening link tag, 3: closing link tag */
					sprintf( __( 'Set your %1$s drop-off point. %2$sConfigure →%3$s', 'parcel-selector' ), $carrier_label, '<a href="' . esc_url( $_etyk_dropoff_url ) . '" target="_blank">', '</a>' ) : '',
			);
		}

		// 5. Label service — connection enrolled (service_id is auto-configured via VPS).
		$installation_id   = get_option( 'parcselect_installation_id', '' );
		$service_connected = get_option( 'parcselect_activated', '0' );
		$service_ok        = ! empty( $installation_id ) && '1' === $service_connected;
		$checks['service'] = array(
			'ok'    => $service_ok,
			'label' => __( 'Label service', 'parcel-selector' ),
			'value' => $service_ok ? __( 'Ready', 'parcel-selector' ) : '',
			'fix'   => __( 'Connect the label service in the Label tool tab.', 'parcel-selector' ),
		);

		// Overall readiness.
		$ready = true;
		foreach ( $checks as $c ) {
			if ( ! $c['ok'] ) {
				$ready = false;
				break;
			}
		}

		return array(
			'ready'  => $ready,
			'checks' => $checks,
			'data'   => $data,
		);
	}

	/**
	 * Render meta box content.
	 *
	 * @param WP_Post|WC_Order $post_or_order Post or Order object.
	 */
	/** @var bool Prevents double render on HPOS screens. */
	private static $rendered = false;

	public static function render( $post_or_order ) {
		// Static guard — render exactly once per page load.
		if ( self::$rendered ) {
			return;
		}
		self::$rendered = true;

		$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 ) {
			return;
		}

		$order_id = $order->get_id();

		// ── CROSS-INSTALL LEAK GUARD ─────────────────────────────────────
		// Before trusting any _ps_label_* order meta, verify that it was
		// written by THIS plugin installation. If not (e.g. DB carried meta
		// from a previous install / site export / shop migration), wipe it
		// on the spot. Without this guard the metabox would render another
		// shop's "paid" label + proxy a PDF from an unrelated VPS session.
		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 );

		// ── COMPLETED: show label + history ──
		if ( 'completed' === $status ) {
			// v4.1.4: wrap in psle-layout3 > el-col-pdf (760px embed + pulse animation).
			// Loads stepper CSS (confetti, accordion, pulse). 5 kolumn — stage 1+2+3+4 minimized done,
			// stage 5 prominent. User może click-expand każdą kolumnę (TODO: click-expand JS).
			if ( class_exists( 'Parcselect_Label_Stepper' ) ) {
				Parcselect_Label_Stepper::render_stages_minimized( $order );
			} else {
				self::render_completed( $order );
			}
			return;
		}

		// `paid`/`generating` used to show a native spinner. Now the VPS iframe
		// (pscp-* styled) renders its own spinner when session.status=paid —
		// no need for a separate branch, keep the flow in one iframe for all
		// pre-completed states.

		// ── EVERYTHING ELSE: iframe VPS (PS CryptoPay-styled thank-you-page) ──
		// The VPS embed-label EJS template renders the whole flow:
		//   ① size picker (A/B/C with prices) → ② pay crypto (QR + wallet
		//   buttons) → ③ checking blockchain spinner → ④ label ready.
		// Uses pscp-* CSS classes (stepper, progress bar) — same visuals as
		// PS CryptoPay thank-you page.
		// Plugin does NOT render any native size picker, crypto selector or
		// QR code on its own — everything lives on VPS. Plugin's only jobs
		// here: show a short status header + mount the iframe + show history.
		self::render_iframe_ui( $order, $status );
		return;

	}

	/**
	 * Render info bar with locker + receiver.
	 *
	 * @param WC_Order $order Order object.
	 */
	private static function render_info_bar( $order ) {
		$locker       = $order->get_meta( '_ps_locker_name', true );
		$city         = $order->get_meta( '_ps_locker_city', true );
		$receiver     = trim( $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name() );
		$order_id     = $order->get_id();
		$status       = $order->get_meta( '_ps_label_status', true );
		?>
		<div id="ps-label-wrap" data-order-id="<?php echo absint( $order_id ); ?>" data-status="<?php echo esc_attr( $status ); ?>">
			<div class="etyk-info">
				<span><strong><?php esc_html_e( 'Pickup point:', 'parcel-selector' ); ?></strong> <?php echo esc_html( $locker ); ?><?php echo $city ? ' (' . esc_html( $city ) . ')' : ''; ?></span>
				<span><strong><?php esc_html_e( 'Receiver:', 'parcel-selector' ); ?></strong> <?php echo esc_html( $receiver ); ?></span>
			</div>
		<?php
	}

	/**
	 * Render the iframe-only UI — the VPS embed-label page handles EVERYTHING:
	 * size picker, crypto selector, QR, wallet buttons, status polling,
	 * "label ready" screen. Plugin only provides the container + mounts it via
	 * ps-label-v2.js (which reads iframe_params from wp_localize_script and
	 * builds the <iframe src>). No native UI. No duplicate logic. One source
	 * of truth: map.parcelselector.com/embed/label (EJS template with pscp-*
	 * styling — same visuals as PS CryptoPay thank-you page).
	 *
	 * @param WC_Order $order  Order being viewed.
	 * @param string   $status Current _ps_label_status meta value.
	 */
	private static function render_iframe_ui( $order, $status ) {
		$order_id = $order->get_id();
		// Info bar: carrier + locker + receiver. Helps admin verify the order
		// context BEFORE iframe loads — if the iframe is blank, admin still
		// sees "pickup point X, receiver Y" and knows what's going out.
		$locker   = $order->get_meta( '_ps_locker_name', true );
		$city     = $order->get_meta( '_ps_locker_city', true );
		$receiver = trim( $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name() );
		$error    = $order->get_meta( '_ps_label_error', true );
		?>
		<div id="ps-label-wrap" data-order-id="<?php echo absint( $order_id ); ?>" data-status="<?php echo esc_attr( $status ?: 'none' ); ?>" style="min-height:920px;">
			<div class="etyk-info" style="padding:10px 14px;margin:0 0 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;font-size:12px;color:#475569;display:flex;gap:16px;flex-wrap:wrap;">
				<span><strong><?php esc_html_e( 'Pickup:', 'parcel-selector' ); ?></strong> <?php echo esc_html( $locker ?: '—' ); ?><?php echo $city ? ' (' . esc_html( $city ) . ')' : ''; ?></span>
				<span><strong><?php esc_html_e( 'Receiver:', 'parcel-selector' ); ?></strong> <?php echo esc_html( $receiver ?: '—' ); ?></span>
				<?php if ( $status ) : ?>
				<span><strong><?php esc_html_e( 'Status:', 'parcel-selector' ); ?></strong> <code style="background:#fff;padding:1px 6px;border-radius:3px;font-size:11px;"><?php echo esc_html( $status ); ?></code></span>
				<?php endif; ?>
			</div>

			<?php if ( $error ) : ?>
			<?php
			// NS-57 2026-04-30: NEVER expose 3rd-party supplier names (Furgonetka, InPost,
			// CEB, VPS) to the customer — strip all internal infrastructure references and
			// show generic technical message. Raw error stays in DB for support diagnostics.
			$_safe_error = preg_replace(
				'/\b(Furgonetka|InPost|CEB|VPS|etykietownik|integrator|api\.parcelselector|crypto-etyk-bridge|Oracle|ps-cryptopay|location_description|address_details)\b[^.]*\.?/i',
				'',
				(string) $error
			);
			$_safe_error = preg_replace( '/\s{2,}/', ' ', trim( $_safe_error ) );
			if ( '' === $_safe_error || strlen( $_safe_error ) < 8 ) {
				$_safe_error = __( 'A technical error occurred while generating the label. Please contact office@parcelselector.com to request a refund.', 'parcel-selector' );
			}
			?>
			<div style="padding:14px 16px;margin:0 0 12px;background:linear-gradient(135deg,#fef2f2 0%,#fee2e2 100%);border:2px solid #dc2626;border-radius:10px;color:#7f1d1d;">
				<div style="font-weight:800;font-size:14px;margin-bottom:6px;">
					⚠️ <?php esc_html_e( 'Label creation error', 'parcel-selector' ); ?>
				</div>
				<div style="font-size:12px;line-height:1.6;color:#991b1b;margin-bottom:8px;">
					<?php echo esc_html( mb_substr( $_safe_error, 0, 300 ) ); ?>
				</div>
				<div style="padding:8px 10px;background:#fff;border:1px solid #fecaca;border-radius:6px;font-size:12px;">
					📧 <strong><?php esc_html_e( 'Contact for a refund:', 'parcel-selector' ); ?></strong>
					<a href="mailto:office@parcelselector.com?subject=<?php echo rawurlencode( 'Refund - order #' . $order->get_id() ); ?>" style="color:#dc2626;font-weight:700;text-decoration:none;">office@parcelselector.com</a>
				</div>
			</div>
			<?php endif; ?>

			<?php
			// v4.1.3 (2026-04-25): 3-stage stepper port from embed-label.ejs (consent + sender + size/QR).
			// PS_Label_Stepper renders green stages with accordion minimize.
			if ( class_exists( 'Parcselect_Label_Stepper' ) ) {
				Parcselect_Label_Stepper::render_stages( $order );
			} else {
				echo '<div style="padding:20px;color:#991b1b">PS_Label_Stepper missing — cannot render stages</div>';
			}
			?>
		</div>
		<?php
		// NS-82 2026-04-30: Label history → lewy dolny róg, accordion zwinięty (Stage 1-4 active branch).
		ob_start(); self::render_history( $order ); $_history_html_active = (string) ob_get_clean();
		$_history_event_count_active = 0;
		if ( preg_match_all( '/<li[^>]*class=["\'][^"\']*event[^"\']*["\']/i', $_history_html_active, $_m_li_active ) ) {
			$_history_event_count_active = count( $_m_li_active[0] );
		}
		?>
		<div class="etyk-history-bottom-left" style="margin:14px 0 0;display:flex;justify-content:flex-start;">
			<details style="width:100%;max-width:520px;background:#ecfdf5;border:2px solid #10b981;border-radius:10px;padding:0;box-shadow:0 2px 8px rgba(16,185,129,.12);">
				<summary style="cursor:pointer;padding:12px 16px;font-weight:800;font-size:13px;color:#065f46;letter-spacing:.02em;display:flex;align-items:center;gap:8px;list-style:none;">
					<span style="font-size:16px;">📜</span>
					<?php esc_html_e( 'Label history', 'parcel-selector' ); ?>
					<?php if ( $_history_event_count_active > 0 ) : ?>
						<span style="background:#10b981;color:#fff;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;letter-spacing:.04em;">
							<?php echo (int) $_history_event_count_active; ?> <?php esc_html_e( 'events', 'parcel-selector' ); ?>
						</span>
					<?php endif; ?>
					<span style="margin-left:auto;font-size:11px;color:#059669;font-weight:600;opacity:.7;">▼ <?php esc_html_e( 'click to expand', 'parcel-selector' ); ?></span>
				</summary>
				<div style="padding:0 16px 14px;background:#fff;border-radius:0 0 8px 8px;margin-top:0;">
					<?php echo wp_kses_post( $_history_html_active ); ?>
				</div>
			</details>
		</div>
		<?php
	}


	/**
	 * Render completed state — label ready + download.
	 *
	 * @param WC_Order $order Order object.
	 */
	public static function render_completed( $order ) {
		$order_id      = $order->get_id();
		$tracking      = $order->get_meta( '_ps_label_tracking_number', true );
		// In the VPS-backed flow the PDF lives either on the VPS (until 7d TTL),
		// in the local mirror under uploads/ps-labels/, or on disk in legacy
		// PARCSELECT_LABEL_DIR/labels. Any one of these makes the label downloadable;
		// the AJAX handler (download_label) resolves them in priority order.
		$label_file    = $order->get_meta( '_ps_label_label_file', true );
		$label_url_raw = $order->get_meta( '_ps_label_url', true );
		$label_local   = $order->get_meta( '_ps_label_local_pdf_path', true );
		$label_ready   = ! empty( $label_file ) || ! empty( $label_url_raw ) || ( $label_local && file_exists( $label_local ) );
		$crypto_amount = $order->get_meta( '_ps_label_crypto_amount', true );
		$crypto_coin   = strtoupper( $order->get_meta( '_ps_label_crypto', true ) ?: 'SOL' );
		$price_display = $order->get_meta( '_ps_label_price_display', true );
		$txid          = $order->get_meta( '_ps_label_txid', true );
		$size          = $order->get_meta( '_ps_label_size', true );
		$weight        = $order->get_meta( '_ps_label_weight', true );
		$locker        = $order->get_meta( '_ps_locker_name', true );
		$city          = $order->get_meta( '_ps_locker_city', true );
		$receiver      = trim( $order->get_shipping_first_name() . ' ' . $order->get_shipping_last_name() );
		$carrier       = $order->get_meta( '_ps_label_carrier', true ) ?: 'inpost';
		$carrier_names = self::carrier_names();
		$carrier_label = isset( $carrier_names[ $carrier ] ) ? $carrier_names[ $carrier ] : ucfirst( $carrier );
		$completed_at  = $order->get_meta( '_ps_label_completed_at', true );
		// v4.1.5 (2026-04-25): use CEB tokenized URL meta directly — admin-ajax resolver
		// had broken fallbacks (local mirror EMPTY + VPS 404). _ps_label_url contains the
		// working CEB endpoint z tokenem (HTTP 200 application/pdf).
		$_label_url_meta = (string) $order->get_meta( '_ps_label_url', true );
		// M3: _ps_label_url moze byc URL-em endpointu parcselect_label_pdf.
		// Nonce NIGDY nie jest trzymany w meta (wygasa po 12-24h => "martwa
		// etykieta"). Wstrzykujemy SWIEZY nonce przy kazdym renderze strony —
		// bajty PDF zyja bezterminowo w tabeli DB, wiec etykieta jest dostepna
		// zawsze, dopoki zalogowany pracownik sklepu otwiera zamowienie.
		if ( '' !== $_label_url_meta && class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			$_label_url_meta = Parcselect_Label_Pdf_Store::ensure_fresh_nonce( $_label_url_meta );
		}
		$_label_url = $_label_url_meta ?: admin_url( 'admin-ajax.php?action=parcselect_label_download_label&order_id=' . $order_id . '&nonce=' . wp_create_nonce( 'parcselect_label_download_' . $order_id ) );
		$mascot_url    = plugin_dir_url( dirname( __FILE__ ) ) . 'assets/images/Parcel_Selector_Com.jpg';
		wp_add_inline_style(
			'wp-admin',
			'@keyframes etyk-pulse-btn{0%,100%{box-shadow:0 0 0 0 rgba(249,115,22,.5)}50%{box-shadow:0 0 0 12px rgba(249,115,22,0)}}'
		);
		?>
		<div id="ps-label-wrap" data-order-id="<?php echo absint( $order_id ); ?>" data-status="completed">
		<div class="etyk-layout">
		<div class="etyk-layout-main">

			<?php
			// NS-78 2026-04-30: manual recovery button gdy płatność wykryta ale brak label_url.
			// Klient zapłacił → MUSI dostać etykietę. Jeśli webhook OUT z CEB nie dotarł
			// (Cloudflare WAF, network timeout, etc), admin może ręcznie wyciągnąć z Oracle.
			$_ps_status_meta = (string) $order->get_meta( '_ps_label_status', true );
			$_ps_pi_id_meta  = (string) $order->get_meta( '_ps_label_pi_id', true );
			$_ps_txid_meta   = (string) $order->get_meta( '_ps_label_txid', true );
			// R8 (Konstytucja): 'refund_pending' tez pokazuje panel recovery —
			// zamowienie oplacone bez etykiety > 24h musi dac adminowi przycisk
			// "Wyciagnij z Oracle", nie pusty/martwy metabox.
			$_show_recovery  = ! $label_ready && '' !== $_ps_pi_id_meta
			                   && in_array( $_ps_status_meta, array( 'paid', 'awaiting_payment', 'refund_pending' ), true );
			if ( $_show_recovery ) :
				$_recovery_nonce = wp_create_nonce( 'parcselect_label_consent' );
			?>
			<div style="margin:14px 0;padding:18px 20px;background:linear-gradient(135deg,#fef3c7 0%,#fed7aa 100%);
			     border:2px solid #f59e0b;border-radius:10px;box-shadow:0 4px 14px rgba(245,158,11,.18)">
				<div style="font-size:14px;font-weight:800;color:#92400e;margin-bottom:8px">
					⏱ <?php esc_html_e( 'The label did not arrive automatically?', 'parcel-selector' ); ?>
				</div>
				<div style="font-size:12px;color:#92400e;line-height:1.6;margin-bottom:12px">
					<?php esc_html_e( 'The payment may be confirmed on-chain but the provider webhook is delayed. Click below to let the plugin retrieve the status from the Oracle itself.', 'parcel-selector' ); ?>
				</div>
				<button type="button" id="psle-recover-btn" data-order="<?php echo absint( $order_id ); ?>"
				        data-nonce="<?php echo esc_attr( $_recovery_nonce ); ?>"
				        style="padding:10px 22px;background:linear-gradient(135deg,#f59e0b 0%,#d97706 100%);color:#fff;
				        border:0;border-radius:999px;font-weight:800;font-size:13px;cursor:pointer;
				        box-shadow:0 4px 12px rgba(217,119,6,.4);transition:transform .15s ease;">
					🔄 <?php esc_html_e( 'RETRIEVE THE LABEL FROM THE ORACLE', 'parcel-selector' ); ?>
				</button>
				<div id="psle-recover-result" style="display:none;margin-top:10px;padding:10px;background:#fff;
				     border-radius:6px;font-size:12px;color:#92400e;line-height:1.5"></div>
				<div style="margin-top:10px;font-size:11px;color:#b45309">
					<?php esc_html_e( 'If the problem persists, please contact', 'parcel-selector' ); ?>
					<a href="mailto:office@parcelselector.com?subject=Order%20%23<?php echo absint( $order_id ); ?>%20-%20no%20label" style="color:#b45309;font-weight:700">office@parcelselector.com</a>.
				</div>
			</div>
			<?php
			// Inline JS for the recovery button — registered via dummy handle so the
			// plugin emits no raw <script> tag from PHP.
			if ( ! wp_script_is( 'parcselect-label-recovery', 'registered' ) ) {
				wp_register_script( 'parcselect-label-recovery', '', array(), PARCSELECT_VERSION, true );
			}
			wp_enqueue_script( 'parcselect-label-recovery' );
			wp_add_inline_script(
				'parcselect-label-recovery',
				"(function(){var btn=document.getElementById('psle-recover-btn');if(!btn)return;btn.addEventListener('click',function(){var oid=btn.getAttribute('data-order');var nonce=btn.getAttribute('data-nonce');var rs=document.getElementById('psle-recover-result');var orig=btn.innerHTML;btn.disabled=true;btn.innerHTML='⏳ Checking the Oracle...';var fd=new FormData();fd.append('action','parcselect_label_recover');fd.append('nonce',nonce);fd.append('order_id',oid);fetch(ajaxurl,{method:'POST',body:fd,credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){if(j&&j.success){rs.innerHTML='<strong>Oracle status:</strong> '+(j.data.oracle_status||'?')+(j.data.oracle_txid?'<br><strong>TXID:</strong> <code style=\"font-size:10px\">'+j.data.oracle_txid+'</code>':'')+'<br>'+(j.data.message||'');rs.style.display='block';if(j.data.oracle_status==='paid'||j.data.oracle_status==='completed'){setTimeout(function(){window.location.reload();},3000);}}else{rs.innerHTML='<strong style=\"color:#dc2626\">Error:</strong> '+((j&&j.data&&j.data.detail)||(j&&j.data&&j.data.message)||'unknown');rs.style.display='block';}btn.innerHTML=orig;btn.disabled=false;}).catch(function(e){rs.innerHTML='<strong style=\"color:#dc2626\">Network error:</strong> '+(e&&e.message?e.message:'unknown');rs.style.display='block';btn.innerHTML=orig;btn.disabled=false;});});})();"
			);
			endif;
			?>

			<?php
			// NS-80 2026-04-30: tracking link nad etykietą (powrót feature, było wcześniej).
			// InPost tracking URL https://inpost.pl/sledzenie-przesylek?number=<tracking>
			$_tracking_meta = (string) $order->get_meta( '_ps_label_tracking_number', true );
			if ( $label_ready && '' !== $_tracking_meta ) :
				$_tracking_url = class_exists( 'Parcselect_Label_Carrier' )
					? Parcselect_Label_Carrier::get_tracking_url( $carrier, $_tracking_meta )
					: 'https://inpost.pl/sledzenie-przesylek?number=' . rawurlencode( $_tracking_meta );
			?>
			<div style="margin:0 0 10px;display:flex;justify-content:center;">
				<a href="<?php echo esc_url( $_tracking_url ); ?>" target="_blank" rel="noopener nofollow"
				   style="display:inline-flex;align-items:center;gap:8px;padding:10px 20px;
				   background:linear-gradient(135deg,#1e293b 0%,#0f172a 100%);color:#fff;
				   text-decoration:none;border-radius:999px;font-weight:800;font-size:13px;
				   box-shadow:0 4px 12px rgba(0,0,0,.25);letter-spacing:.04em;
				   transition:transform .15s ease, box-shadow .15s ease;"
				   onmouseover="this.style.transform='scale(1.05)';this.style.boxShadow='0 6px 16px rgba(0,0,0,.4)';"
				   onmouseout="this.style.transform='';this.style.boxShadow='0 4px 12px rgba(0,0,0,.25)';"
				   title="<?php esc_attr_e( 'Check the parcel status on the carrier website (opens in a new tab)', 'parcel-selector' ); ?>">
					📦 <?php esc_html_e( 'TRACK THE PARCEL', 'parcel-selector' ); ?>
					<code style="background:rgba(255,255,255,.18);padding:2px 8px;border-radius:6px;font-family:monospace;font-size:11px;letter-spacing:.02em"><?php echo esc_html( $_tracking_meta ); ?></code>
					<span style="font-size:13px;opacity:.85">→</span>
				</a>
			</div>
			<?php endif; ?>

			<?php if ( $label_ready ) :
				// NS-69 2026-04-30: ramka A6 portrait (105/148) MAX 380px szerokości (zmniejszone
				// z 480) — gwarancja że cała etykieta widoczna w sidebar metabox.
				// Transparent overlay div = 100% klikalny obszar (nie da sie zaznaczyc tekstu).
				// Click → window.open CENTROWANE okno z inline HTML wrapper:
				//   - PDF iframe na pełen ekran
				//   - Floating PRINT button (zielony, prawy górny róg)
				//   - @media print: @page A4 + iframe pozycjonowany top-left, rozmiar A5 (148×210mm)
				// DEV-2 2026-05-16: separator zalezny od tego czy URL ma query string.
				// Lokalny PDF (_ps_label_url po fetch_and_store) nie ma '?' -> '&inline=1'
				// dawalo nazwe pliku literalnie "...pdf&inline=1" -> nginx 404.
				$_label_print_glue  = ( false === strpos( $_label_url, '?' ) ) ? '?' : '&';
				$_label_print_url   = esc_url( $_label_url . $_label_print_glue . 'inline=1' );
				$_label_print_url_b = base64_encode( $_label_print_url );
				wp_add_inline_style(
					'wp-admin',
					'@keyframes psle-label-pulse{0%,100%{box-shadow:0 4px 14px rgba(16,185,129,.18);border-color:#cbd5e1}50%{box-shadow:0 8px 28px rgba(16,185,129,.45);border-color:#10b981}}'
					. '.etyk-label-frame{animation:psle-label-pulse 2.4s ease-in-out infinite;transition:border-color .25s ease,transform .2s ease,box-shadow .25s ease}'
					. '.etyk-label-frame:hover{animation:none;box-shadow:0 12px 32px rgba(16,185,129,.55)!important;border-color:#059669!important;transform:translateY(-2px)}'
					. '.etyk-label-overlay{position:absolute;inset:0;cursor:pointer;background:transparent;z-index:5}'
					. '.etyk-label-overlay:hover ~ .etyk-print-badge{transform:scale(1.08)}'
				);
			?>
			<div style="width:100%;display:flex;justify-content:center;padding:8px 0;margin:0;background:transparent;">
				<div class="etyk-label-frame"
				     style="width:100%;max-width:380px;background:#fff;border:2px solid #cbd5e1;border-radius:10px;overflow:hidden;box-sizing:border-box;aspect-ratio:105 / 148;position:relative;">
					<iframe src="<?php echo esc_url( $_label_print_url ); ?>#toolbar=0&navpanes=0&scrollbar=0&view=Fit&zoom=page-fit"
					        style="width:100%;height:100%;display:block;background:#fff;border:0;margin:0;padding:0;pointer-events:none;user-select:none;"
					        scrolling="no" frameborder="0"></iframe>
					<!-- Transparent overlay = cały panel klikalny, blokuje text-selection PDF -->
					<div class="etyk-label-overlay"
					     onclick="psleOpenPrintWindow('<?php echo esc_js( $_label_print_url ); ?>')"
					     title="<?php esc_attr_e( 'Click to open the label in a centered window with a print button', 'parcel-selector' ); ?>"></div>
					<?php
					// NS-79 2026-04-30: same-origin AJAX proxy URL for download.
					// M3: jesli etykieta jest w endpoincie parcselect_label_pdf,
					// linkujemy WPROST do niego (attachment, swiezy nonce). Stary
					// proxy download_pdf robi server-side wp_remote_get bez ciastek
					// admina => nie zautoryzowalby sie do naszego wlasnego endpointu.
					if ( class_exists( 'Parcselect_Label_Pdf_Store' )
						&& false !== strpos( (string) $_label_url, 'action=parcselect_label_pdf' ) ) {
						$_dl_proxy_url = Parcselect_Label_Pdf_Store::nonced_url( $order_id, false );
					} else {
						$_dl_proxy_url = admin_url( 'admin-ajax.php' )
							. '?action=parcselect_label_download_pdf'
							. '&order_id=' . absint( $order_id )
							. '&nonce=' . wp_create_nonce( 'parcselect_label_download_' . $order_id );
					}
					?>
					<a class="etyk-download-orange" href="<?php echo esc_url( $_dl_proxy_url ); ?>"
					   download="etykieta-<?php echo absint( $order_id ); ?>.pdf"
					   onclick="event.stopPropagation();"
					   style="position:absolute;top:10px;right:12px;background:linear-gradient(135deg,#f97316 0%,#ea580c 100%);color:#fff;font-size:11px;font-weight:800;padding:7px 14px;border-radius:999px;box-shadow:0 4px 12px rgba(234,88,12,.55);letter-spacing:.04em;text-decoration:none;display:inline-flex;align-items:center;gap:5px;transition:transform .15s ease, box-shadow .15s ease;z-index:7;"
					   onmouseover="this.style.transform='scale(1.08)';this.style.boxShadow='0 6px 16px rgba(234,88,12,.7)';"
					   onmouseout="this.style.transform='';this.style.boxShadow='0 4px 12px rgba(234,88,12,.55)';"
					   title="<?php esc_attr_e( 'Download the label PDF to disk', 'parcel-selector' ); ?>">⬇ <?php esc_html_e( 'DOWNLOAD', 'parcel-selector' ); ?></a>
					<div class="etyk-print-badge" style="position:absolute;bottom:10px;right:12px;background:linear-gradient(135deg,#10b981 0%,#059669 100%);color:#fff;font-size:11px;font-weight:800;padding:6px 12px;border-radius:999px;box-shadow:0 4px 12px rgba(16,185,129,.5);pointer-events:none;letter-spacing:.04em;z-index:6;transition:transform .2s ease;">
						🖨️ <?php esc_html_e( 'KLIKNIJ — DRUKUJ', 'parcel-selector' ); ?>
					</div>
				</div>
			</div>
			<?php
			if ( ! wp_script_is( 'parcselect-label-print-popup', 'registered' ) ) {
				wp_register_script(
					'parcselect-label-print-popup',
					PARCSELECT_PLUGIN_URL . 'assets/js/parcselect-label-print-popup.js',
					array(),
					PARCSELECT_VERSION,
					true
				);
			}
			wp_enqueue_script( 'parcselect-label-print-popup' );
			?>
			<?php
				wp_register_script( 'parcselect-label-print', '', array( 'jquery' ), PARCSELECT_VERSION, true );
				wp_enqueue_script( 'parcselect-label-print' );
				wp_add_inline_script( 'parcselect-label-print', 'jQuery(document).on("click", ".etyk-print-label-btn", function() {
					var url = jQuery(this).data("url");
					var w = window.open(url, "_blank", "width=800,height=1000");
					if (w) { w.onload = function() { w.print(); }; }
				});' );
			endif; ?>

		</div>
		<div class="etyk-layout-mascot">
			<a href="https://parcelselector.com" target="_blank" rel="noopener" class="etyk-mascot-link" title="ParcelSelector.com">
				<img src="<?php echo esc_url( $mascot_url ); ?>" alt="ParcelSelector" class="etyk-mascot" />
			</a>
		</div>
		</div>
		<?php
		// NS-82 2026-04-30: Label history przeniesione z wnętrza ramki etykiety do
		// LEWEGO DOLNEGO ROGU całego Etykietownika. Domyślnie ZWINIĘTY accordion (<details>).
		// Style spójne z innymi panelami (zielony border #10b981, light bg #ecfdf5).
		ob_start(); self::render_history( $order ); $_history_html = (string) ob_get_clean();
		$_history_event_count = 0;
		if ( preg_match_all( '/<li[^>]*class=["\'][^"\']*event[^"\']*["\']/i', $_history_html, $_m_li ) ) {
			$_history_event_count = count( $_m_li[0] );
		}
		?>
		<div class="etyk-history-bottom-left" style="margin:14px 0 0;display:flex;justify-content:flex-start;">
			<details style="width:100%;max-width:520px;background:#ecfdf5;border:2px solid #10b981;border-radius:10px;padding:0;box-shadow:0 2px 8px rgba(16,185,129,.12);">
				<summary style="cursor:pointer;padding:12px 16px;font-weight:800;font-size:13px;color:#065f46;letter-spacing:.02em;display:flex;align-items:center;gap:8px;list-style:none;">
					<span style="font-size:16px;">📜</span>
					<?php esc_html_e( 'Label history', 'parcel-selector' ); ?>
					<?php if ( $_history_event_count > 0 ) : ?>
						<span style="background:#10b981;color:#fff;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;letter-spacing:.04em;">
							<?php echo (int) $_history_event_count; ?> <?php esc_html_e( 'events', 'parcel-selector' ); ?>
						</span>
					<?php endif; ?>
					<span style="margin-left:auto;font-size:11px;color:#059669;font-weight:600;opacity:.7;">▼ <?php esc_html_e( 'click to expand', 'parcel-selector' ); ?></span>
				</summary>
				<div style="padding:0 16px 14px;background:#fff;border-radius:0 0 8px 8px;margin-top:0;">
					<?php echo wp_kses_post( $_history_html ); ?>
				</div>
			</details>
		</div>
		</div>
		<?php
	}

	/**
	 * Enqueue JS + CSS only on order edit pages.
	 *
	 * @param string $hook Current admin page hook.
	 */
	public static function enqueue_assets( $hook ) {
		$screen = get_current_screen();
		if ( ! $screen ) {
			return;
		}

		$order_screens = array( 'shop_order', 'woocommerce_page_wc-orders' );
		if ( ! in_array( $screen->id, $order_screens, true ) ) {
			return;
		}

		// iframe loader only — QR + all UI render on VPS.
		// Register ps-label-js as an empty placeholder handle so the existing
		// wp_localize_script('parcselect-label-js', 'parcselectLabelConfig', ...) call
		// below still works. The file itself is gone; no native JS runs.
		wp_register_script( 'parcselect-label-js', '', array( 'jquery' ), PARCSELECT_VERSION, true );
		wp_enqueue_script( 'parcselect-label-js' );
		// v4.1.2: ps-label-v2.js (iframe loader) DEZAKTYWOWANE — native Oracle Krok 2 zamiast.
		// wp_enqueue_script( 'parcselect-label-js-v2', PARCSELECT_LABEL_URL . 'assets/js/ps-label-v2.js', ... ) — nieaktywne.

		// Build iframe_params for the VPS embed. The v2 loader script reads
		// these and renders an iframe pointing at map.parcelselector.com/embed/label.
		// Empty/missing on the orders LIST screen (no specific order) — v2 just
		// skips iframe rendering then. On the order EDIT screen we resolve the
		// current order id and ship every datum the VPS needs to bootstrap.
		$iframe_params = self::build_iframe_params();
		$use_iframe    = '1' === (string) get_option( 'parcselect_label_use_iframe', '1' ); // default ON, admin can flip
		$vps_embed     = rtrim( (string) get_option( 'parcselect_label_mapserver_url', 'https://map.parcelselector.com' ), '/' ) . '/embed/label';

		wp_localize_script( 'parcselect-label-js', 'parcselectLabelConfig', array(
			'ajaxUrl'        => admin_url( 'admin-ajax.php' ),
			'nonce'          => wp_create_nonce( 'parcselect_label_nonce' ),
			// Iframe flow controls — read by ps-label-v2.js. If `use_iframe`
			// is false OR `iframe_params` is empty, v2 quietly noops and the
			// native UI from ps-label.js stays in charge — single source of
			// truth, no duplicate render race.
			'use_iframe'     => $use_iframe,
			'vps_embed_url'  => $vps_embed,
			'iframe_params'  => $iframe_params,
			// Diagnostics so the v2 loader can log + fall back if the VPS
			// origin refuses framing (X-Frame-Options / CSP frame-ancestors).
			'iframe_probe_url' => $vps_embed . '?probe=1',
			'iframe_fallback_native' => true,
			'i18n'           => array(
				'awaitingPayment' => __( 'Waiting for payment...', 'parcel-selector' ),
				'paymentOk'       => __( 'Payment confirmed!', 'parcel-selector' ),
				'generating'      => __( 'Generating label...', 'parcel-selector' ),
				'completed'       => __( 'Label ready!', 'parcel-selector' ),
				'expired'         => __( 'EXPIRED', 'parcel-selector' ),
				'payLabel'        => __( 'Pay for label', 'parcel-selector' ),
				'amount'          => __( 'Amount:', 'parcel-selector' ),
				'address'         => __( 'Address:', 'parcel-selector' ),
				'timeLeft'        => __( 'Time left:', 'parcel-selector' ),
				'copied'          => __( 'Copied!', 'parcel-selector' ),
				'copy'            => __( 'Copy', 'parcel-selector' ),
				'downloadPdf'     => __( 'Download label (PDF)', 'parcel-selector' ),
				'print'           => __( 'Print', 'parcel-selector' ),
				'trackingNumber'  => __( 'Tracking number:', 'parcel-selector' ),
				'error'           => __( 'Error. Please try again.', 'parcel-selector' ),
				'fetchingPrice'   => __( 'Calculating price...', 'parcel-selector' ),
				'iframeBlocked'   => __( 'VPS payment iframe blocked by browser. Falling back to native UI — payment still works.', 'parcel-selector' ),
			),
		) );

		wp_enqueue_style( 'parcselect-label-css', PARCSELECT_LABEL_URL . 'assets/css/ps-label.css', array(), PARCSELECT_VERSION );
	}

	/**
	 * Compose the iframe_params shipped to the VPS embed page. Built from the
	 * SAME order context used by ajax.create_session — the VPS can construct
	 * the session immediately on iframe load instead of an extra round-trip.
	 *
	 * Returns array() if no order context (e.g. orders list screen) — the v2
	 * loader treats that as "do not render iframe".
	 */
	private static function build_iframe_params() {
		// Find the current order id from the admin URL (HPOS or legacy).
		$order_id = 0;
		if ( isset( $_GET['post'] ) && 'shop_order' === get_post_type( absint( $_GET['post'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$order_id = absint( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		} elseif ( isset( $_GET['id'] ) && isset( $_GET['action'] ) && 'edit' === $_GET['action'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$order_id = absint( $_GET['id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		}
		if ( ! $order_id ) {
			return array();
		}
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return array();
		}
		$shipping = $order->get_address( 'shipping' );
		$get_ship = function( $key, $fallback = '' ) use ( $order, $shipping ) {
			$val = isset( $shipping[ $key ] ) ? $shipping[ $key ] : '';
			if ( $val ) return $val;
			$billing_key = 'get_billing_' . $key;
			if ( method_exists( $order, $billing_key ) ) {
				return call_user_func( array( $order, $billing_key ) );
			}
			return $fallback;
		};
		// Locker resolved with the same dual-pass strategy as ajax.create_session.
		// First try strict id-only keys, then accept `_ps_locker_name` if it
		// matches the InPost code regex — PS checkout historically stores the
		// code under the `_name` field despite the naming.
		$locker_id   = '';
		$locker_name = '';
		foreach ( array( '_ps_locker_id', '_inpost_paczkomat_id', '_ps_customer_locker_id', '_parcel_locker_id', '_shipping provider_point_code', '_easypack_point' ) as $k ) {
			$v = trim( (string) $order->get_meta( $k, true ) );
			if ( '' !== $v ) { $locker_id = $v; break; }
		}
		if ( '' === $locker_id ) {
			foreach ( array( '_ps_locker_name', '_inpost_point_name' ) as $k ) {
				$v = trim( (string) $order->get_meta( $k, true ) );
				if ( '' !== $v && preg_match( '/^[A-Z]{2,4}\d+[A-Z]?$/i', $v ) ) {
					$locker_id = $v;
					break;
				}
			}
		}
		foreach ( array( '_ps_locker_name', '_inpost_point_label', '_ps_locker_label', '_inpost_point_name' ) as $k ) {
			$v = (string) $order->get_meta( $k, true );
			if ( '' !== $v ) { $locker_name = $v; break; }
		}
		$phone = method_exists( $order, 'get_shipping_phone' ) ? $order->get_shipping_phone() : '';
		if ( ! $phone ) $phone = $order->get_billing_phone();
		$locker_address = $order->get_meta( '_ps_locker_address', true )
			?: $order->get_meta( '_ps_locker_address1', true );
		$locker_postcode = $order->get_meta( '_ps_locker_postcode', true )
			?: $order->get_meta( '_ps_locker_post_code', true );
		// Consent state — iframe reads these to decide which stage to open on.
		$consent_service = 'yes' === $order->get_meta( '_ps_label_consent_service', true ) ? '1' : '';
		// Sender consent is implicit once sender options are all filled; iframe
		// uses this flag to skip stage 2 when sender is already configured.
		$sender_fields_filled = ! empty( get_option( 'parcselect_label_sender_name' ) )
			&& ! empty( get_option( 'parcselect_label_sender_street' ) )
			&& ! empty( get_option( 'parcselect_label_sender_city' ) )
			&& ! empty( get_option( 'parcselect_label_sender_postcode' ) )
			&& ! empty( get_option( 'parcselect_label_sender_phone' ) );
		$consent_sender = $sender_fields_filled ? '1' : '';

		return array(
			'order_id'       => (int) $order_id,
			'carrier'        => $order->get_meta( '_ps_locker_carrier', true ) ?: 'inpost',
			'locker_id'      => $locker_id,
			'locker_name'    => $locker_name,
			'locker_address' => (string) $locker_address,
			'locker_city'    => (string) $order->get_meta( '_ps_locker_city', true ),
			'locker_postcode' => (string) $locker_postcode,
			'consent_service' => $consent_service,
			'consent_sender'  => $consent_sender,
			'recv_name'      => trim( $get_ship( 'first_name' ) . ' ' . $get_ship( 'last_name' ) ),
			'recv_phone'     => (string) $phone,
			'recv_email'     => (string) $order->get_billing_email(),
			'recv_city'      => (string) $get_ship( 'city' ),
			'recv_postcode'  => (string) $get_ship( 'postcode' ),
			'snd_name'       => (string) get_option( 'parcselect_label_sender_name', '' ),
			'snd_company'    => (string) get_option( 'parcselect_label_sender_company', '' ),
			'snd_street'     => (string) get_option( 'parcselect_label_sender_street', '' ),
			'snd_city'       => (string) get_option( 'parcselect_label_sender_city', '' ),
			'snd_postcode'   => (string) get_option( 'parcselect_label_sender_postcode', '' ),
			'snd_phone'      => (string) get_option( 'parcselect_label_sender_phone', '' ),
			'snd_email'      => (string) get_option( 'parcselect_label_sender_email', get_option( 'admin_email', '' ) ),
			'installation'   => (string) get_option( 'parcselect_installation_id', '' ),
			'callback_url'   => esc_url_raw( rest_url( 'ps/v1/label-callback' ) ),
		);
	}

	/**
	 * Get country flag as inline HTML image (works on all platforms including Windows).
	 *
	 * v4.2.5: Local PNG flags (was flagcdn.com CDN). Renders as colored flag everywhere,
	 * unlike Unicode emoji which Windows shows as plain text letters.
	 *
	 * @param string $code Two-letter country code (e.g. PL, DE, GB).
	 * @return string HTML img tag or empty string if invalid.
	 */
	private static function country_flag( $code ) {
		$code = strtolower( trim( $code ) );
		if ( 2 !== strlen( $code ) || ! ctype_alpha( $code ) ) {
			return '';
		}
		return '<img src="' . esc_url( PARCSELECT_PLUGIN_URL . 'assets/flags/20x15/' . $code . '.png' ) . '" width="20" height="15" alt="' . esc_attr( strtoupper( $code ) ) . '" style="vertical-align:middle;margin-right:3px;border:1px solid #e5e7eb;border-radius:2px;" />';
	}

	/**
	 * Render blockchain explorer links for a transaction.
	 *
	 * @param string $coin Crypto symbol (BTC, ETH, SOL, etc.).
	 * @param string $txid Transaction hash.
	 * @return string HTML with explorer links.
	 */
	private static function render_explorer_links( $coin, $txid ) {
		$coin = strtoupper( $coin );
		$explorers = array(
			'BTC' => array(
				'Blockstream'    => 'https://blockstream.info/tx/',
				'Blockchain.com' => 'https://www.blockchain.com/btc/tx/',
				'Mempool'        => 'https://mempool.space/tx/',
				'BlockCypher'    => 'https://live.blockcypher.com/btc/tx/',
			),
			'ETH' => array(
				'Blockscout'  => 'https://eth.blockscout.com/tx/',
				'Etherscan'   => 'https://etherscan.io/tx/',
				'Ethplorer'   => 'https://ethplorer.io/tx/',
				'Beaconcha.in' => 'https://beaconcha.in/tx/',
			),
			'SOL' => array(
				'Solscan'      => 'https://solscan.io/tx/',
				'Solana FM'    => 'https://solana.fm/tx/',
				'SolanaBeach'  => 'https://solanabeach.io/transaction/',
				'Explorer'     => 'https://explorer.solana.com/tx/',
			),
			'BNB' => array(
				'BscScan' => 'https://bscscan.com/tx/',
			),
			'XRP' => array(
				'XRPScan'  => 'https://xrpscan.com/tx/',
				'Bithomp'  => 'https://bithomp.com/explorer/',
			),
			'LTC' => array(
				'Blockchair'    => 'https://blockchair.com/litecoin/transaction/',
				'Litecoinspace' => 'https://litecoinspace.org/tx/',
			),
			'XMR' => array(
				'XMRchain' => 'https://xmrchain.net/tx/',
			),
			'USDT' => array(
				'Tronscan' => 'https://tronscan.org/#/transaction/',
			),
		);

		if ( ! isset( $explorers[ $coin ] ) || empty( $txid ) ) {
			return '';
		}

		$links = array();
		foreach ( $explorers[ $coin ] as $name => $base_url ) {
			$links[] = '<a href="' . esc_url( $base_url . $txid ) . '" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none">' . esc_html( $name ) . '</a>';
		}

		return '<span class="etyk-explorer-links">' . implode( ' · ', $links ) . '</span>';
	}

	/**
	 * Render full history timeline — payments, attempts, errors, successes.
	 * Always visible at the bottom of the metabox.
	 *
	 * @param WC_Order $order Order object.
	 */
	private static function render_history( $order ) {
		$status        = $order->get_meta( '_ps_label_status', true );
		$session_id    = $order->get_meta( '_ps_label_session_id', true );
		$created       = $order->get_meta( '_ps_label_created_at', true );
		$paid_at       = $order->get_meta( '_ps_label_paid_at', true );
		$completed_at  = $order->get_meta( '_ps_label_completed_at', true );
		$crypto        = strtoupper( $order->get_meta( '_ps_label_crypto', true ) );
		$crypto_amount = $order->get_meta( '_ps_label_crypto_amount', true );
		$crypto_wallet = $order->get_meta( '_ps_label_crypto_wallet', true );
		$txid          = $order->get_meta( '_ps_label_txid', true );
		$carrier       = $order->get_meta( '_ps_label_carrier', true );
		$size          = $order->get_meta( '_ps_label_size', true );
		$weight        = $order->get_meta( '_ps_label_weight', true );
		$price_display = $order->get_meta( '_ps_label_price_display', true );
		$error         = $order->get_meta( '_ps_label_error', true );
		$tracking      = $order->get_meta( '_ps_label_tracking_number', true );
		$label_file    = $order->get_meta( '_ps_label_label_file', true );
		$furg_id       = $order->get_meta( '_ps_label_shipping provider_id', true );
		$retry_count   = (int) $order->get_meta( '_ps_label_retry_count', true );

		if ( ! $status && ! $error ) {
			return; // No history to show.
		}

		// Build timeline events.
		$events = array();

		if ( $created ) {
			$events[] = array(
				'time'  => $created,
				'icon'  => '&#x1F4E6;',
				'color' => '#2563eb',
				'title' => __( 'Session created', 'parcel-selector' ),
				'detail' => ( $size ? $size . ' | ' : '' ) . ( $weight ? $weight . ' kg | ' : '' ) . ( $price_display ? $price_display : '' ),
			);
		}

		if ( $crypto && $crypto_amount ) {
			$events[] = array(
				'time'  => $created,
				'icon'  => '&#x1F4B0;',
				'color' => '#7c3aed',
				'title' => sprintf(
					/* translators: 1: crypto amount, 2: crypto symbol */
					__( 'Crypto selected: %1$s %2$s', 'parcel-selector' ),
					$crypto_amount, $crypto
				),
				'detail' => $crypto_wallet ? substr( $crypto_wallet, 0, 12 ) . '...' . substr( $crypto_wallet, -6 ) : '',
			);
		}

		if ( $txid && $paid_at ) {
			$events[] = array(
				'time'  => $paid_at,
				'icon'  => '&#x2705;',
				'color' => '#16a34a',
				'title' => __( 'Payment confirmed', 'parcel-selector' ),
				'detail' => 'TXID: <code style="font-size:10px">' . esc_html( substr( $txid, 0, 16 ) ) . '...</code><br>'
					. self::render_explorer_links( $crypto, $txid ),
			);
		}

		if ( $carrier ) {
			$events[] = array(
				'time'  => $paid_at ? $paid_at : $created,
				'icon'  => '&#x1F69A;',
				'color' => '#0891b2',
				'title' => sprintf(
					/* translators: %s: carrier name */
					__( 'Carrier: %s', 'parcel-selector' ),
					strtoupper( $carrier )
				),
				'detail' => $furg_id ? 'Package ID: ' . esc_html( $furg_id ) : '',
			);
		}

		if ( $error ) {
			// Humanize raw API errors into readable messages.
			$error_display = Parcselect_Label_Ajax::humanize_error( $error );
			$events[] = array(
				'time'  => $paid_at ? $paid_at : $created,
				'icon'  => '&#x274C;',
				'color' => '#dc2626',
				'title' => __( 'Label generation failed', 'parcel-selector' ),
				'detail' => esc_html( mb_substr( $error_display, 0, 300 ) ),
			);
		}

		if ( $retry_count > 0 ) {
			$events[] = array(
				'time'  => '',
				'icon'  => '&#x1F504;',
				'color' => '#f59e0b',
				'title' => sprintf(
					/* translators: %d: number of retries */
					__( 'Auto-retried %d time(s)', 'parcel-selector' ),
					$retry_count
				),
				'detail' => '',
			);
		}

		if ( $completed_at ) {
			$_label_detail = '';
			if ( $tracking ) {
				$_label_detail = __( 'Tracking:', 'parcel-selector' ) . ' <strong>' . esc_html( $tracking ) . '</strong>';
			}
			if ( $label_file ) {
				$_label_detail .= ( $_label_detail ? '<br>' : '' ) . __( 'PDF:', 'parcel-selector' ) . ' ' . esc_html( $label_file );
			}
			$events[] = array(
				'time'  => $completed_at,
				'icon'  => '&#x2705;',
				'color' => '#16a34a',
				'title' => __( 'Label generated successfully', 'parcel-selector' ),
				'detail' => $_label_detail ? $_label_detail : __( 'Label ready for download', 'parcel-selector' ),
			);
		}

		// Also include WC order notes related to ps-label.
		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id(), 'limit' => 20 ) );
		foreach ( $notes as $note ) {
			$content = $note->content;
			if ( stripos( $content, 'etykietownik' ) !== false
				|| stripos( $content, 'label payment' ) !== false
				|| stripos( $content, 'shipping label' ) !== false
				|| stripos( $content, 'INTERNAL_PAYMENT' ) !== false
				|| stripos( $content, 'blockchain' ) !== false ) {
				$events[] = array(
					'time'  => $note->date_created->date( 'Y-m-d H:i:s' ),
					'icon'  => '&#x1F4DD;',
					'color' => '#6b7280',
					'title' => __( 'Note', 'parcel-selector' ),
					'detail' => esc_html( mb_substr( $content, 0, 200 ) ),
				);
			}
		}

		// Merge in PS_Label_Debug trail — pulls structured events written by
		// every stage of the pipeline (AJAX, webhook, VPS callback, download
		// fallback decisions). Filters to admin-relevant events; drops noisy
		// per-request ajax.*.entry/exit timing pairs.
		$trail = (array) $order->get_meta( '_ps_label_debug_log', true );
		$admin_events = array(
			// ── webhook inbound ───────────────────────────────────────────
			'webhook.raw'                 => array( '&#x1F4E5;', '#475569', __( 'Webhook raw payload received', 'parcel-selector' ) ),
			'webhook.received'            => array( '&#x1F4E8;', '#2563eb', __( 'Webhook received from VPS', 'parcel-selector' ) ),
			'webhook.auth_failed'         => array( '&#x1F512;', '#dc2626', __( 'Webhook rejected: auth failed', 'parcel-selector' ) ),
			'webhook.order_updated'       => array( '&#x1F4DD;', '#16a34a', __( 'Order meta updated from webhook', 'parcel-selector' ) ),
			'webhook.duplicate_ignored'   => array( '&#x1F501;', '#94a3b8', __( 'Duplicate webhook ignored', 'parcel-selector' ) ),
			'webhook.completed.format.ok'       => array( '&#x2705;',  '#16a34a', __( 'Webhook completed payload — format OK', 'parcel-selector' ) ),
			'webhook.completed.format.mismatch' => array( '&#x26A0;',  '#dc2626', __( 'Webhook completed — missing required fields', 'parcel-selector' ) ),
			'webhook.error.format.ok'           => array( '&#x2705;',  '#475569', __( 'Webhook error payload — format OK', 'parcel-selector' ) ),
			'webhook.error.format.mismatch'     => array( '&#x26A0;',  '#dc2626', __( 'Webhook error — malformed payload', 'parcel-selector' ) ),
			// ── retry / recheck ───────────────────────────────────────────
			'ajax.retry.start'            => array( '&#x1F504;', '#f59e0b', __( 'Retry initiated', 'parcel-selector' ) ),
			'ajax.retry.response'         => array( '&#x1F504;', '#f59e0b', __( 'Retry response from VPS', 'parcel-selector' ) ),
			'ajax.retry.network_error'    => array( '&#x26A0;',  '#dc2626', __( 'Retry network error', 'parcel-selector' ) ),
			'ajax.recheck.start'          => array( '&#x1F50D;', '#0891b2', __( 'On-chain recheck triggered', 'parcel-selector' ) ),
			'ajax.recheck.response'       => array( '&#x1F50D;', '#0891b2', __( 'On-chain recheck result', 'parcel-selector' ) ),
			'ajax.create_session.not_enrolled' => array( '&#x26A0;', '#dc2626', __( 'Session create: plugin not enrolled', 'parcel-selector' ) ),
			'ajax.create_session.no_locker'    => array( '&#x26A0;', '#dc2626', __( 'Session create: no locker meta found', 'parcel-selector' ) ),
			'ajax.create_session.missing_sender' => array( '&#x26A0;', '#dc2626', __( 'Session create: missing sender fields', 'parcel-selector' ) ),
			'locker.resolved'             => array( '&#x1F4CD;', '#2563eb', __( 'Locker resolved', 'parcel-selector' ) ),
			// ── status polling ────────────────────────────────────────────
			'check_status.vps_polled'     => array( '&#x1F4E1;', '#0891b2', __( 'VPS status polled', 'parcel-selector' ) ),
			'check_status.polled_vps_completed' => array( '&#x2705;', '#16a34a', __( 'VPS status poll: completed — tracking pulled', 'parcel-selector' ) ),
			// ── download 3-stage fallback ─────────────────────────────────
			'download.attempt.local'      => array( '&#x1F4BE;', '#475569', __( 'Download attempt: local mirror', 'parcel-selector' ) ),
			'download.attempt.legacy'     => array( '&#x1F4BE;', '#475569', __( 'Download attempt: legacy plugin dir', 'parcel-selector' ) ),
			'download.attempt.vps'        => array( '&#x1F310;', '#475569', __( 'Download attempt: VPS stream', 'parcel-selector' ) ),
			'download.attempt.vps.http'   => array( '&#x1F310;', '#475569', __( 'Download attempt: VPS HTTP response', 'parcel-selector' ) ),
			'download.attempt.vps.skipped' => array( '&#x23E9;', '#94a3b8', __( 'Download attempt: VPS skipped (no URL + no session)', 'parcel-selector' ) ),
			'download.url_reconstructed_from_session' => array( '&#x1F527;', '#2563eb', __( 'Download URL rebuilt from session_id', 'parcel-selector' ) ),
			'download.served_local'       => array( '&#x1F4BE;', '#16a34a', __( 'PDF served from local mirror', 'parcel-selector' ) ),
			'download.served_legacy'      => array( '&#x1F4BE;', '#16a34a', __( 'PDF served from legacy plugin dir', 'parcel-selector' ) ),
			'download.served_vps'         => array( '&#x1F310;', '#16a34a', __( 'PDF streamed from VPS', 'parcel-selector' ) ),
			'download.result'             => array( '&#x1F4DC;', '#0891b2', __( 'Download final decision', 'parcel-selector' ) ),
			'download.all_fallbacks_failed' => array( '&#x26A0;', '#dc2626', __( 'All PDF fallbacks failed', 'parcel-selector' ) ),
			// ── PDF mirror ────────────────────────────────────────────────
			'pdf.mirror.request'          => array( '&#x1F4E4;', '#475569', __( 'PDF mirror: requesting from VPS', 'parcel-selector' ) ),
			'pdf.mirror.http'             => array( '&#x1F4E4;', '#475569', __( 'PDF mirror: VPS HTTP response', 'parcel-selector' ) ),
			'pdf.mirrored'                => array( '&#x1F5C2;', '#16a34a', __( 'PDF mirrored locally', 'parcel-selector' ) ),
			'pdf.mirror_failed'           => array( '&#x26A0;',  '#dc2626', __( 'Local PDF mirror failed', 'parcel-selector' ) ),
			'pdf.mirror_invalid'          => array( '&#x26A0;',  '#dc2626', __( 'Mirrored file is not a valid PDF', 'parcel-selector' ) ),
			// ── enrollment ────────────────────────────────────────────────
			'enroll.ok'                   => array( '&#x2705;',  '#16a34a', __( 'Plugin enrolled with VPS', 'parcel-selector' ) ),
			'enroll.http_error'           => array( '&#x26A0;',  '#dc2626', __( 'Enrollment HTTP error', 'parcel-selector' ) ),
			'enroll.network_error'        => array( '&#x26A0;',  '#dc2626', __( 'Enrollment network error', 'parcel-selector' ) ),
			// ── VPS session client HTTP + format ──────────────────────────
			'session.create.request'      => array( '&#x1F4E4;', '#475569', __( 'VPS session: request sent', 'parcel-selector' ) ),
			'session.create.http'         => array( '&#x1F4E1;', '#475569', __( 'VPS session: HTTP response', 'parcel-selector' ) ),
			'session.create.ok'           => array( '&#x1F4E6;', '#2563eb', __( 'Session created on VPS', 'parcel-selector' ) ),
			'session.create.format.ok'       => array( '&#x2705;',  '#16a34a', __( 'VPS session response — format OK', 'parcel-selector' ) ),
			'session.create.format.mismatch' => array( '&#x26A0;',  '#dc2626', __( 'VPS session response — format mismatch', 'parcel-selector' ) ),
			'session.create.http_error'   => array( '&#x26A0;',  '#dc2626', __( 'VPS session HTTP error', 'parcel-selector' ) ),
			'session.create.network_error' => array( '&#x26A0;', '#dc2626', __( 'VPS session network error', 'parcel-selector' ) ),
			'session.create.retry'        => array( '&#x1F501;',  '#f59e0b', __( 'VPS session — retrying after transient error', 'parcel-selector' ) ),
			'session.status.request'      => array( '&#x1F4E4;', '#475569', __( 'VPS status: request sent', 'parcel-selector' ) ),
			'session.status.http'         => array( '&#x1F4E1;', '#475569', __( 'VPS status: HTTP response', 'parcel-selector' ) ),
			'session.status.format.ok'       => array( '&#x2705;',  '#16a34a', __( 'VPS status response — format OK', 'parcel-selector' ) ),
			'session.status.format.mismatch' => array( '&#x26A0;',  '#dc2626', __( 'VPS status response — format mismatch', 'parcel-selector' ) ),
			'session.status.completed.format.ok'       => array( '&#x2705;',  '#16a34a', __( 'VPS completed status — format OK', 'parcel-selector' ) ),
			'session.status.completed.format.mismatch' => array( '&#x26A0;',  '#dc2626', __( 'VPS completed status — missing tracking/label_url', 'parcel-selector' ) ),
			'session.confirm.request'     => array( '&#x1F4E4;', '#475569', __( 'VPS confirm: request sent', 'parcel-selector' ) ),
			'session.confirm.http'        => array( '&#x1F4E1;', '#475569', __( 'VPS confirm: HTTP response', 'parcel-selector' ) ),
			'session.confirm.format.ok'       => array( '&#x2705;',  '#16a34a', __( 'VPS confirm response — format OK', 'parcel-selector' ) ),
			'session.confirm.format.mismatch' => array( '&#x26A0;',  '#dc2626', __( 'VPS confirm response — format mismatch', 'parcel-selector' ) ),
			'session.confirm.not_enrolled' => array( '&#x1F512;', '#dc2626', __( 'VPS confirm skipped — not enrolled', 'parcel-selector' ) ),
		);
		foreach ( $trail as $entry ) {
			if ( ! is_array( $entry ) || empty( $entry['event'] ) ) {
				continue;
			}
			$code = $entry['event'];
			if ( ! isset( $admin_events[ $code ] ) ) {
				// Show any warning/error not explicitly mapped so nothing critical gets hidden.
				if ( ! empty( $entry['level'] ) && 'info' !== $entry['level'] ) {
					$icon  = '&#x26A0;';
					$color = ( 'error' === $entry['level'] ) ? '#dc2626' : '#f59e0b';
					$title = sprintf(
						/* translators: 1: log level, 2: event code */
						__( '%1$s: %2$s', 'parcel-selector' ),
						strtoupper( $entry['level'] ),
						$code
					);
				} else {
					continue;
				}
			} else {
				list( $icon, $color, $title ) = $admin_events[ $code ];
			}
			$detail_lines = array();
			// Correlation header: request_id + session_id visible per event so the
			// admin can trace which PHP request / flow the line belongs to.
			$corr_bits = array();
			if ( ! empty( $entry['request_id'] ) ) {
				$corr_bits[] = 'rid=<code style="font-size:10px">' . esc_html( $entry['request_id'] ) . '</code>';
			}
			if ( ! empty( $entry['session_id'] ) ) {
				$corr_bits[] = 'sid=<code style="font-size:10px">' . esc_html( $entry['session_id'] ) . '</code>';
			}
			if ( $corr_bits ) {
				$detail_lines[] = '<span style="color:#94a3b8;font-size:10px">' . implode( ' · ', $corr_bits ) . '</span>';
			}
			if ( ! empty( $entry['data'] ) && is_array( $entry['data'] ) ) {
				foreach ( $entry['data'] as $k => $v ) {
					if ( is_array( $v ) ) {
						$v = wp_json_encode( $v, JSON_UNESCAPED_SLASHES );
					}
					$detail_lines[] = esc_html( $k ) . '=<code style="font-size:10px">'
						. esc_html( (string) mb_substr( (string) $v, 0, 120 ) ) . '</code>';
				}
			}
			// ISO8601 → "Y-m-d H:i:s" (UTC) for display + sort alignment.
			$when = '';
			if ( ! empty( $entry['ts'] ) ) {
				$ts = strtotime( $entry['ts'] );
				if ( $ts ) {
					$when = gmdate( 'Y-m-d H:i:s', $ts );
				}
			}
			$events[] = array(
				'time'   => $when,
				'icon'   => $icon,
				'color'  => $color,
				'title'  => $title,
				'detail' => implode( '<br>', $detail_lines ),
			);
		}

		if ( empty( $events ) ) {
			return;
		}

		// Sort by time descending (newest first).
		usort( $events, function( $a, $b ) {
			return strcmp( $b['time'], $a['time'] );
		} );
		?>
		<div class="etyk-history" style="margin-top:12px;">
			<details>
				<summary style="cursor:pointer;font-size:12px;font-weight:600;color:#64748b;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;user-select:none;">
					&#x1F4CB; <?php esc_html_e( 'Label history', 'parcel-selector' ); ?>
					<span style="font-weight:400;color:#94a3b8;">(<?php echo count( $events ); ?> <?php esc_html_e( 'events', 'parcel-selector' ); ?>)</span>
					<span style="float:right;font-size:10px;color:#cbd5e1;">&#x25BC;</span>
				</summary>
				<div class="etyk-timeline">
					<?php foreach ( $events as $ev ) : ?>
					<div class="etyk-event" style="--etyk-event-color:<?php echo esc_attr( $ev['color'] ); ?>">
						<div class="etyk-event-dot"></div>
						<div class="etyk-event-header">
							<span class="etyk-event-title">
								<?php echo $ev['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
								<?php echo esc_html( $ev['title'] ); ?>
							</span>
							<?php if ( $ev['time'] ) : ?>
							<span class="etyk-event-time"><?php echo esc_html( $ev['time'] ); ?></span>
							<?php endif; ?>
						</div>
						<?php if ( $ev['detail'] ) : ?>
						<div class="etyk-event-detail">
							<?php echo wp_kses_post( $ev['detail'] ); ?>
						</div>
						<?php endif; ?>
					</div>
					<?php endforeach; ?>
				</div>
			</details>
		</div>
		<?php
	}
}

Parcselect_Label_Metabox::init();
