<?php
/**
 * Plugin Name:       Parcel Selector — Pickup Point & Locker Map for WooCommerce
 * Plugin URI:        https://parcelselector.com/
 * Description:       InPost parcel locker map for WooCommerce checkout. Poland: InPost Paczkomaty. Classic + ANUNAKI map engines. Unlimited orders, no API key required. The only WordPress plugin that lets you pay for shipping labels with Solana (SOL) cryptocurrency.
 * Version:           4.5.2
 * Requires at least: 5.3
 * Tested up to:      7.0
 * Requires PHP:      7.4
 * Author:            Piotr Chrumkacz
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       parcel-selector
 * Domain Path:       /languages
 *
 * WC requires at least: 5.0
 * WC tested up to:      10.7
 * Requires Plugins:     woocommerce
 *
 * @package ParcelSelector
 */

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

// ============================================================================
// Dual-plugin guard — prevent conflicts if both free and pro are active.
// ============================================================================
// v4.3.7 (NS-24 fix 2026-04-30) — Unified API base = subdomena map.parcelselector.com.
// Wczesniej hardcoded root parcelselector.com — wymagal consent (NS-23 phone-home gate),
// blokowal punkty dla shopow spoza OVH (Hetzner FREE_FARM / self-host clients).
// map.parcelselector.com jest na infrastructure whitelist w class-ps-consent-manager.php
// → nie wymaga consent, dziala out-of-the-box dla DOWOLNEJ instalacji.
// Filterable dla self-hosted setupow (rzadko).
if ( ! defined( 'PARCSELECT_API_BASE' ) ) {
	define( 'PARCSELECT_API_BASE', apply_filters( 'parcselect_api_base_url', 'https://parcelselector.com' ) );
}

if ( ! function_exists( 'parcselect_phone_strip_pl_prefix' ) ) {
function parcselect_phone_strip_pl_prefix( $raw ) {
	$digits = preg_replace( '/\D/', '', (string) $raw );
	if ( strlen( $digits ) === 11 && substr( $digits, 0, 2 ) === '48' ) {
		$digits = substr( $digits, 2 );
	}
	return $digits;
}
}
if ( ! function_exists( 'parcselect_phone_normalize_pl' ) ) {

if ( ! function_exists( 'parcselect_label_ajax_mark_failed' ) ) {
function parcselect_label_ajax_mark_failed() {
	check_ajax_referer( 'parcselect_label_consent', 'nonce' );
	if ( ! current_user_can( 'edit_shop_orders' ) ) {
		wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
	}
	$order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
	$reason   = isset( $_POST['reason'] ) ? sanitize_text_field( wp_unslash( $_POST['reason'] ) ) : 'unknown';
	$order    = $order_id ? wc_get_order( $order_id ) : null;
	if ( ! $order ) {
		wp_send_json_error( array( 'message' => 'order_not_found' ), 404 );
	}
	$current = (string) $order->get_meta( '_ps_label_status', true );
	if ( 'completed' === $current ) {
		wp_send_json_success( array( 'already_completed' => true ) ); // idempotent — nie nadpisuj completed
	}
	$order->update_meta_data( '_ps_label_status', 'error' );
	// FIX E 2026-05-14: dodac FAIL entry do _ps_label_history zeby Label history pokazywal Fail event.
	$_history = (array) $order->get_meta( '_ps_label_history', true );
	if ( ! is_array( $_history ) ) { $_history = array(); }
	$_history[] = array(
		'event'      => 'label_failed',
		'at'         => current_time( 'mysql', 1 ),
		'reason'     => $reason,
		'pi_id'      => (string) $order->get_meta( '_ps_label_pi_id', true ),
		'txid'       => (string) $order->get_meta( '_ps_label_txid', true ),
		'dust_id'    => (string) $order->get_meta( '_ps_label_dust_id', true ),
		'sol_amount' => (string) $order->get_meta( '_ps_label_sol_amount', true ),
		'paid_at'    => (string) $order->get_meta( '_ps_label_paid_at', true ),
	);
	$order->update_meta_data( '_ps_label_history', $_history );
	$order->update_meta_data( '_ps_label_failed_at', current_time( 'mysql', 1 ) );
	$order->update_meta_data( '_ps_label_error_reason', $reason );
	$order->add_order_note( 'PARCEL SELECTOR: Etykieta NIE wygenerowana w 90s od paid. Reason: ' . $reason . '. Wymagana manualna obsluga lub zwrot na office@parcelselector.com.' );
	$order->save();

	// P5 #24 — admin email z pelnymi danymi do identyfikacji (TYLKO admin, nie klient).
	$_adm_email = get_option( 'admin_email' );
	if ( $_adm_email && is_email( $_adm_email ) ) {
		$_subj  = 'PARCEL SELECTOR FAIL: Order #' . $order_id . ' - etykieta niewygenerowana';
		$_pi    = (string) $order->get_meta( '_ps_label_pi_id', true );
		$_txid  = (string) $order->get_meta( '_ps_label_txid', true );
		$_wall  = (string) $order->get_meta( '_ps_label_sol_wallet', true );
		$_memo  = (string) $order->get_meta( '_ps_label_memo', true );
		$_paid  = (string) $order->get_meta( '_ps_label_paid_at', true );
		$_pln   = (string) $order->get_meta( '_ps_label_price_pln', true );
		$_sol   = (string) $order->get_meta( '_ps_label_sol_amount', true );
		$_body  = "Witaj,\n\nOrder #{$order_id} - etykieta InPost NIE wygenerowana w 90s od potwierdzenia platnosci Solana.\nWymagana manualna obsluga lub zwrot srodkow na zyczenie klienta.\n\n";
		$_body .= "Dane do identyfikacji:\n";
		$_body .= "Order ID: #{$order_id}\n";
		$_body .= "PID: {$_pi}\n";
		$_body .= "Kwota: {$_pln} PLN / {$_sol} SOL\n";
		$_body .= "TxID: {$_txid}\n";
		$_body .= "Wallet: {$_wall}\n";
		$_body .= "Memo: {$_memo}\n";
		$_body .= "Paid at (UTC): {$_paid}\n";
		$_body .= "Reason: {$reason}\n";
		$_body .= "Sklep: " . home_url() . "\n\n";
		$_body .= "Klient zobaczy panel FAIL z prosba o kontakt na office@parcelselector.com.\nDo wgladu: " . admin_url( 'admin.php?page=wc-orders&action=edit&id=' . $order_id ) . "\n";
		wp_mail( $_adm_email, $_subj, $_body );
	}

	wp_send_json_success( array( 'status' => 'error', 'reason' => $reason ) );
}
}
add_action( 'wp_ajax_parcselect_label_mark_failed', 'parcselect_label_ajax_mark_failed' );

function parcselect_phone_normalize_pl( $raw ) {
	$digits = parcselect_phone_strip_pl_prefix( $raw );
	if ( strlen( $digits ) !== 9 ) {
		return '';
	}
	return '+48' . $digits;
}
}

if ( defined( 'PARCSELECT_VERSION' ) ) {
	add_action( 'admin_notices', function() {
		echo '<div class="notice notice-error"><p>';
		echo '<strong>Parcel Selector:</strong> ';
		echo esc_html__( 'Two versions of Parcel Selector are active. Please deactivate one to avoid conflicts.', 'parcel-selector' );
		echo '</p></div>';
	} );
	return; // Stop loading — the other copy already registered everything.
}

// ============================================================================
// Constants
// ============================================================================

if ( ! defined( 'PARCSELECT_VERSION' ) ) {
	define( 'PARCSELECT_VERSION', '4.5.2' );
}
if ( ! defined( 'PARCSELECT_PLUGIN_DIR' ) ) {
	define( 'PARCSELECT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
}
if ( ! defined( 'PARCSELECT_PLUGIN_URL' ) ) {
	define( 'PARCSELECT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
}
if ( ! defined( 'PARCSELECT_PLUGIN_FILE' ) ) {
	define( 'PARCSELECT_PLUGIN_FILE', __FILE__ );
}
if ( ! defined( 'PARCSELECT_DEBUG' ) ) {
	define( 'PARCSELECT_DEBUG', false );
}

/**
 * Safe filemtime — returns PARCSELECT_VERSION on failure (read-only hosts, opcode cache).
 *
 * @param string $path Absolute file path.
 * @return string Version string for cache busting.
 */
function parcselect_file_version( $path ) {
	$t = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- intentional fallback.
	return $t ? (string) $t : PARCSELECT_VERSION;
}

/**
 * Cache-buster for our own assets.
 *
 * Some environments run behind a CDN that caches static files, combined with a
 * filter that strips the standard ?ver= query from asset URLs. The result is a
 * version-less URL the CDN caches indefinitely, so plugin updates are never seen.
 * Re-append a ?v=<filemtime> marker for our own handles at a late priority so the
 * URL changes whenever a file changes (CDNs key cache on the full URL incl. query).
 *
 * @param string $src    Script/style source URL.
 * @param string $handle Registered handle.
 * @return string
 */
function parcselect_cache_bust_src( $src, $handle ) {
	if ( ! is_string( $handle ) || ( 0 !== strpos( $handle, 'parcselect' ) && 0 !== strpos( $handle, 'ps-' ) ) ) {
		return $src;
	}
	if ( ! is_string( $src ) || false === strpos( $src, PARCSELECT_PLUGIN_URL ) ) {
		return $src;
	}
	$clean = preg_replace( '/\?.*$/', '', $src );
	$rel   = str_replace( PARCSELECT_PLUGIN_URL, '', $clean );
	$ver   = parcselect_file_version( PARCSELECT_PLUGIN_DIR . $rel );
	return add_query_arg( 'v', $ver, $src );
}
add_filter( 'script_loader_src', 'parcselect_cache_bust_src', 1001, 2 );
add_filter( 'style_loader_src', 'parcselect_cache_bust_src', 1001, 2 );

/**
 * HPOS + Blocks Compatibility
 */
add_action( 'before_woocommerce_init', function() {
	if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) {
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
	}
} );

// ============================================================================
// Data migration: legacy prefix → parcselect_ (one-time, idempotent).
// Runs on activation AND plugins_loaded so auto-update users (who skip the
// activation hook) also get their options + user meta + WC instance settings
// renamed before any other plugin code reads them. Idempotent — guarded by
// option parcselect_migration_v1_done.
// ============================================================================
require_once __DIR__ . '/includes/class-parcselect-migration.php';
add_action( 'plugins_loaded', array( 'Parcselect_Migration', 'maybe_run' ), 1 );

// ============================================================================
// Security: sslverify can only be disabled on local dev environments.
// Prevents malicious plugins from MitM-ing VPS communication in production.
// ============================================================================
add_filter( 'parcselect_label_sslverify', function( $verify ) {
	if ( false === $verify ) {
		$host = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( sanitize_text_field( $_SERVER['HTTP_HOST'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$is_dev = $host && ( preg_match( '/\.(local|test|dev|localhost)$/i', $host ) || 'localhost' === $host || '127.0.0.1' === $host );
		if ( ! $is_dev ) {
			return true; // Force SSL verification in production.
		}
	}
	return $verify;
}, 9999 ); // Priority 9999 = runs last, overrides any malicious filter.

// ============================================================================
// Bootstrap — load includes after plugins are loaded (WooCommerce available)
// ============================================================================

add_action( 'plugins_loaded', 'parcselect_init' );

// WP.org compliance: od WP 4.6 tlumaczenia wtyczki (slug == text domain ==
// 'parcel-selector') sa ladowane automatycznie just-in-time z /languages/.
// Reczne load_plugin_textdomain() jest odradzane (Plugin Check) i zbedne.

/**
 * Initialize plugin — runs after all plugins loaded.
 */
if ( ! function_exists( 'parcselect_init' ) ) {
function parcselect_init() {
	// Bail if WooCommerce is not active
	if ( ! class_exists( 'WooCommerce' ) ) {
		add_action( 'admin_notices', 'parcselect_wc_missing_notice' );
		return;
	}

	// Load all includes
	$includes = array(
		'includes/class-ps-order-data.php',
		'includes/class-ps-order-save.php',
		// class-ps-order-display.php replaced by WOOCOMMERCE_ORDER module (order-render-utils + class-order-*.php)
		'includes/class-ps-ajax.php',
		'includes/class-ps-checkout.php',
		'includes/class-ps-blocks-integration.php',
		// class-ps-shipping-method.php is loaded on woocommerce_shipping_init (see below)
		'includes/class-ps-diagnostics.php',
		'includes/class-ps-geo-preload.php',
		'includes/class-ps-error-monitor.php',
		// class-ps-commercial-download.php intentionally excluded — demo-site only, not distributed.
	);

	foreach ( $includes as $file ) {
		if ( file_exists( PARCSELECT_PLUGIN_DIR . $file ) ) {
			require_once PARCSELECT_PLUGIN_DIR . $file;
		}
	}


	// Load WOOCOMMERCE_ORDER module — render-utils must be first (ps_render_locker_card dependency)
	$_ps_wc_order_files = array(
		'WOOCOMMERCE_ORDER/order-render-utils.php',
		'WOOCOMMERCE_ORDER/class-order-meta-boxes.php',
		'WOOCOMMERCE_ORDER/class-order-labels.php',
		'WOOCOMMERCE_ORDER/class-order-frontend-view.php',
		'WOOCOMMERCE_ORDER/class-order-email-view.php',
	);
	foreach ( $_ps_wc_order_files as $_ps_wc_file ) {
		if ( file_exists( PARCSELECT_PLUGIN_DIR . $_ps_wc_file ) ) {
			require_once PARCSELECT_PLUGIN_DIR . $_ps_wc_file;
		}

// v4.2.7: Plugin-wide consent + telemetry (WP.org compliance) — REUSE z PRO v3.5.0.
require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-legal.php';
require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-data-collector.php';
require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-consent-manager.php';
	}
	unset( $_ps_wc_order_files, $_ps_wc_file );



	// Load LOCAL SEO module — auto-generated pickup point pages for store's city.
	// Gives store owners indexable local content (Google sees: "Pickup points in Warszawa").
	// Each page links back to parcelselector.com — builds domain authority.
	if ( file_exists( PARCSELECT_PLUGIN_DIR . 'includes/class-ps-local-seo.php' ) ) {
		require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-local-seo.php';
		Parcselect_Local_SEO::init();
	}

	// Load PANEL_ADMINA — admin only, prevents unnecessary loading on frontend pages
	if ( is_admin() && file_exists( PARCSELECT_PLUGIN_DIR . 'admin-panel/panel-init.php' ) ) {
		require_once PARCSELECT_PLUGIN_DIR . 'admin-panel/panel-init.php';
	}

	// PS_LABEL module — native metabox UI (admin) + autotrigger listener (global).
	// Loaded in ALL contexts because PS_Label_Autotrigger hooks woocommerce_payment_complete
	// which fires during REST API webhook calls (is_admin() is FALSE then). Admin-only UI
	// classes (metabox, settings, ajax) gate themselves internally via admin_menu / admin_init
	// hooks, so loading them in frontend context is a no-op.
	if ( file_exists( PARCSELECT_PLUGIN_DIR . 'PS_LABEL/init.php' ) ) {
		require_once PARCSELECT_PLUGIN_DIR . 'PS_LABEL/init.php';
	}

	// Label Webhook — REST endpoint for VPS to send tracking data (compat with existing VPS callbacks).
	if ( file_exists( PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-webhook.php' ) ) {
		require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-webhook.php';
		Parcselect_Label_Webhook::init();
	}

	// Label Embed — iframe + postMessage bridge (port 1:1 z PRO 18cb575).
	// Renders `#ps-label-iframe` metabox + handles consent/sender AJAX.
	// Replaces legacy class-ps-label-metabox.php (native UI + 5000 linii dupliktatow).
	if ( file_exists( PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-embed.php' ) ) {
		require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-embed.php';
	}

	// v4.3.8 TIER 1 AUTOCFG — plug-and-play installer (WC auto-install,
	// fakecron fallback, Oracle pool health check, UUID auto-gen, status notice).
	// Goal: wtyczka działa "out of the box" na każdym sklepie WordPress bez ręcznej konfiguracji.
	if ( file_exists( PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-autoconfig.php' ) ) {
		require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-label-autoconfig.php';
		Parcselect_Label_Autoconfig::init();
	}

	// GDPR: Register privacy policy content suggestion — must be on admin_init or later
	// (Calling wp_add_privacy_policy_content before admin_init triggers a _doing_it_wrong() notice)
	add_action( 'admin_init', 'parcselect_register_privacy_policy_content' );

	// GDPR: Personal data export/erasure (WP Tools → Privacy)
	add_filter( 'wp_privacy_personal_data_exporters', 'parcselect_register_data_exporter' );
	add_filter( 'wp_privacy_personal_data_erasers',   'parcselect_register_data_eraser' );
}
}

// ============================================================================
// GDPR — Privacy Policy, Data Export & Erasure
// ============================================================================

if ( ! function_exists( 'parcselect_register_privacy_policy_content' ) ) {
function parcselect_register_privacy_policy_content() {
	if ( function_exists( 'wp_add_privacy_policy_content' ) ) {
		wp_add_privacy_policy_content( 'ParcelSelector', wp_kses_post( parcselect_get_privacy_policy_text() ) );
	}
}
}

if ( ! function_exists( 'parcselect_get_privacy_policy_text' ) ) {
function parcselect_get_privacy_policy_text() {
	return '<h2>' . esc_html__( 'ParcelSelector — Parcel Locker Data', 'parcel-selector' ) . '</h2>'
		. '<p>' . esc_html__( 'When you select an InPost parcel locker at checkout, we store the following with your order: locker ID, address, city, postal code, and country. This data is required for delivery and is part of your order record.', 'parcel-selector' ) . '</p>'
		. '<p>' . esc_html__( 'When using the ANUNAKI map engine (default), your browser requests locker location data from parcelselector.com using only the selected country code (e.g. "pl"). No personal data is transmitted to parcelselector.com.', 'parcel-selector' ) . '</p>';
}
}

if ( ! function_exists( 'parcselect_register_data_exporter' ) ) {
function parcselect_register_data_exporter( $exporters ) {
	$exporters['parcel-selector'] = array(
		'exporter_friendly_name' => __( 'ParcelSelector Locker Selections', 'parcel-selector' ),
		'callback'               => 'parcselect_export_personal_data',
	);
	return $exporters;
}
}

if ( ! function_exists( 'parcselect_export_personal_data' ) ) {
function parcselect_export_personal_data( $email, $page = 1 ) {
	if ( ! function_exists( 'wc_get_orders' ) ) {
		return array( 'data' => array(), 'done' => true );
	}
	$export_items = array();
	$orders = wc_get_orders( array( 'customer' => $email, 'limit' => 20, 'offset' => ( $page - 1 ) * 20 ) );
	foreach ( $orders as $order ) {
		if ( ! function_exists( 'parcselect_get_order_data' ) ) continue;
		$data = parcselect_get_order_data( $order );
		if ( ! $data['has_data'] ) continue;
		$p = $data['paczkomat'];
		$export_items[] = array(
			'group_id'    => 'parcselect_locker_selections',
			'group_label' => __( 'Parcel Locker Selections', 'parcel-selector' ),
			'item_id'     => 'ps-order-' . $order->get_id(),
			'data'        => array(
				array( 'name' => __( 'Order #', 'parcel-selector' ),  'value' => $order->get_id() ),
				array( 'name' => __( 'Locker ID', 'parcel-selector' ), 'value' => $p['name'] ),
				array( 'name' => __( 'Address', 'parcel-selector' ),   'value' => $p['address1'] . ', ' . $p['city'] . ' ' . $p['post_code'] ),
				array( 'name' => __( 'Country', 'parcel-selector' ),   'value' => $p['country_name'] ),
			),
		);
	}
	return array( 'data' => $export_items, 'done' => count( $orders ) < 20 );
}
}

if ( ! function_exists( 'parcselect_register_data_eraser' ) ) {
function parcselect_register_data_eraser( $erasers ) {
	$erasers['parcel-selector'] = array(
		'eraser_friendly_name' => __( 'ParcelSelector Customer Popup Data', 'parcel-selector' ),
		'callback'             => 'parcselect_erase_personal_data',
	);
	return $erasers;
}
}

if ( ! function_exists( 'parcselect_erase_personal_data' ) ) {
function parcselect_erase_personal_data( $email, $page = 1 ) {
	$removed = 0;
	if ( ! function_exists( 'wc_get_orders' ) ) {
		return array( 'items_removed' => 0, 'items_retained' => 0, 'messages' => array(), 'done' => true );
	}
	$orders = wc_get_orders( array( 'customer' => $email, 'limit' => 20, 'offset' => ( $page - 1 ) * 20 ) );
	// All PII-bearing meta keys to erase.
	$_erase_keys = array(
		// Customer popup data.
		'_ps_customer_first_name', '_ps_customer_last_name', '_ps_customer_email', '_ps_customer_phone',
		// Label / tracking PII.
		'_ps_label_tracking_number', '_ps_label_txid', '_ps_label_crypto', '_ps_label_crypto_amount',
		'_ps_label_url', '_ps_label_local_pdf_url', '_ps_label_session_id',
		'_ps_label_consent_date', '_ps_label_debug_log',
		// Locker selection PII.
		'_ps_locker_name', '_ps_locker_address', '_ps_locker_city', '_ps_locker_post_code',
	);
	foreach ( $orders as $order ) {
		$_order_removed = 0;
		foreach ( $_erase_keys as $key ) {
			if ( $order->get_meta( $key ) ) {
				$order->delete_meta_data( $key );
				$_order_removed++;
			}
		}
		// Delete locally mirrored label PDF if it exists.
		$_pdf_path = (string) $order->get_meta( '_ps_label_local_pdf_path', true );
		if ( $_pdf_path && file_exists( $_pdf_path ) ) {
			wp_delete_file( $_pdf_path );
			$order->delete_meta_data( '_ps_label_local_pdf_path' );
			$_order_removed++;
		}
		// v4.4.5 M3 — skasuj bajty PDF z tabeli DB (zawierają dane adresowe).
		if ( class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			$_pdf_db_deleted = Parcselect_Label_Pdf_Store::delete_db_pdf( $order->get_id() );
			if ( $_pdf_db_deleted > 0 ) {
				$order->delete_meta_data( '_ps_label_pdf_db_id' );
				$_order_removed++;
			}
		}
		if ( $_order_removed ) {
			$order->save();
			$removed += $_order_removed;
		}
	}
	return array( 'items_removed' => $removed, 'items_retained' => 0, 'messages' => array(), 'done' => count( $orders ) < 20 );
}
}

/**
 * Admin notice when WooCommerce is not active.
 */
if ( ! function_exists( 'parcselect_wc_missing_notice' ) ) {
function parcselect_wc_missing_notice() {
	echo '<div class="notice notice-error"><p>';
	echo esc_html__( 'Parcel Selector requires WooCommerce to be installed and activated.', 'parcel-selector' );
	echo '</p></div>';
}
}

// ============================================================================
// WooCommerce shipping method registration
// ============================================================================

// Register shipping method on woocommerce_shipping_init —
// guarantees WC_Shipping_Method exists + method always registered.
add_action( 'woocommerce_shipping_init', 'parcselect_load_and_register_shipping_method' );
if ( ! function_exists( 'parcselect_load_and_register_shipping_method' ) ) {
function parcselect_load_and_register_shipping_method() {
	// Load method class (if not yet loaded via includes array)
	if ( ! class_exists( 'Parcselect_Shipping_Method' ) ) {
		if ( file_exists( PARCSELECT_PLUGIN_DIR . 'includes/class-ps-shipping-method.php' ) ) {
			require_once PARCSELECT_PLUGIN_DIR . 'includes/class-ps-shipping-method.php';
		}
	}
	// Register method in WooCommerce (directly in the same hook)
	add_filter( 'woocommerce_shipping_methods', 'parcselect_register_shipping_method' );
}
}

if ( ! function_exists( 'parcselect_register_shipping_method' ) ) {
function parcselect_register_shipping_method( $methods ) {
	$methods['parcselect_inpost_paczkomat'] = 'Parcselect_Shipping_Method';
	return $methods;
}
}

// ============================================================================
// Map engine — ANUNAKI (live API) or Classic (local JSON files)
// ============================================================================

/**
 * Complex auto-creation logic for shipping zone on activation (Layer 8)
 */
// Single activation hook — parcselect_activate() handles all setup
// (was previously split into ps_plugin_activate + ps_activate — merged to avoid race on managed hosts)
if ( ! function_exists( 'parcselect_plugin_activate' ) ) {
	function parcselect_plugin_activate() {
		// Delegate to main activation function
		if ( function_exists( 'parcselect_activate' ) ) {
			parcselect_activate();
		} else {
			update_option( 'parcselect_needs_zone_setup', 'yes' );
		}
	}
}

add_action( 'admin_init', 'parcselect_auto_setup_shipping_zone' );
if ( ! function_exists( 'parcselect_auto_setup_shipping_zone' ) ) {
	/**
	 * Ensure the dedicated "InPost Parcel Lockers" shipping zone exists with all
	 * 22 supported countries and has InPost added to every existing merchant zone.
	 *
	 * Runs on admin_init but uses a version-stamped option so it only does real
	 * work once per plugin version (or when the zone is found to be missing).
	 */
	function parcselect_auto_setup_shipping_zone() {
		if ( ! class_exists( 'WC_Shipping_Zones' ) || ! class_exists( 'WC_Shipping_Zone' ) ) {
			return; // WC not fully loaded yet
		}

		// Fast path: if version matches and setup completed → skip entirely (no DB queries).
		if ( get_option( 'parcselect_zone_setup_version' ) === PARCSELECT_VERSION
			&& get_option( 'parcselect_shipping_zone_id' )
			&& get_option( 'parcselect_zone_setup_complete' )
			&& ! get_option( 'parcselect_needs_zone_setup' ) ) {
			return; // All good — zone was fully configured on this version.
		}

		delete_option( 'parcselect_needs_zone_setup' ); // clear legacy flag
		delete_option( 'parcselect_zone_setup_complete' ); // force full re-check

		$ps_countries = array( 'PL' ); // Free version: InPost Paczkomaty, Poland only.

		try {
			// ── 1. Create or update the dedicated InPost zone ───────────────────
			$ps_zone_id = (int) get_option( 'parcselect_shipping_zone_id', 0 );
			$ps_zone    = $ps_zone_id ? new WC_Shipping_Zone( $ps_zone_id ) : null;

			// Validate stored zone still exists in DB
			if ( ! $ps_zone || ! $ps_zone->get_id() ) {
				$ps_zone = new WC_Shipping_Zone();
				$ps_zone->set_zone_name( 'InPost Parcel Lockers' );
				$ps_zone->set_zone_order( 0 );
				$ps_zone->save();
				update_option( 'parcselect_shipping_zone_id', $ps_zone->get_id() );
			}

			// Always sync the full country list (handles plugin updates adding new countries)
			$locations = array();
			foreach ( $ps_countries as $cc ) {
				$locations[] = array( 'code' => $cc, 'type' => 'country' );
			}
			$ps_zone->set_locations( $locations );
			$ps_zone->save();

			// Add InPost method to our zone if missing
			$has_inpost = false;
			foreach ( $ps_zone->get_shipping_methods() as $zm ) {
				if ( $zm->id === 'parcselect_inpost_paczkomat' ) { $has_inpost = true; break; }
			}
			if ( ! $has_inpost ) {
				$iid = $ps_zone->add_shipping_method( 'parcselect_inpost_paczkomat' );
				if ( $iid ) {
					$m = WC_Shipping_Zones::get_shipping_method( $iid );
					if ( $m ) {
						$m->update_option( 'enabled', 'yes' );
						// v4.3.1: set default cost 19.99 PLN on freshly created instance
						$m->update_option( 'cost', (string) apply_filters( 'parcselect_default_inpost_cost', '19.99' ) );
					}
				}
			}

			// ── 2. Rest of the World — universal fallback ────────────────────────
			$zone_0 = new WC_Shipping_Zone( 0 );
			$has_0  = false;
			foreach ( $zone_0->get_shipping_methods() as $m ) {
				if ( $m->id === 'parcselect_inpost_paczkomat' ) { $has_0 = true; break; }
			}
			if ( ! $has_0 ) {
				$iid = $zone_0->add_shipping_method( 'parcselect_inpost_paczkomat' );
				if ( $iid ) {
					$m = WC_Shipping_Zones::get_shipping_method( $iid );
					if ( $m ) {
						$m->update_option( 'enabled', 'yes' );
						// v4.3.1: set default cost 19.99 PLN on freshly created instance
						$m->update_option( 'cost', (string) apply_filters( 'parcselect_default_inpost_cost', '19.99' ) );
					}
				}
			}

			// ── 2b. v4.3.1 one-time migration: existing instances with empty/0 cost → 19.99 PLN ──
			if ( ! get_option( 'parcselect_default_cost_migration_v431' ) ) {
				$default_cost = (string) apply_filters( 'parcselect_default_inpost_cost', '19.99' );
				$all_zones    = WC_Shipping_Zones::get_zones();
				$all_zones[]  = array( 'id' => 0 ); // include "Rest of the World"
				foreach ( $all_zones as $zinfo ) {
					$zid       = isset( $zinfo['id'] ) ? (int) $zinfo['id'] : (int) $zinfo['zone_id'];
					$this_zone = new WC_Shipping_Zone( $zid );
					foreach ( $this_zone->get_shipping_methods() as $m ) {
						if ( $m->id !== 'parcselect_inpost_paczkomat' ) { continue; }
						$current = $m->get_option( 'cost', '' );
						if ( '' === $current || '0' === $current || '0.00' === $current || 0.0 === (float) $current ) {
							$m->update_option( 'cost', $default_cost );
						}
					}
				}
				update_option( 'parcselect_default_cost_migration_v431', '1', true );
			}

			// ── 3. Other merchant zones — SKIPPED. ──────────────────────────────
			// Previously this injected InPost into every existing zone, including
			// zones like "Local Delivery Only" where merchants don't want it.
			// Merchants can add InPost to specific zones manually via
			// WooCommerce → Settings → Shipping → [Zone] → Add shipping method.

			// Stamp the version + completion flag so we skip fast on every subsequent admin page.
			update_option( 'parcselect_zone_setup_version', PARCSELECT_VERSION );
			update_option( 'parcselect_zone_setup_complete', '1' );
			parcselect_log( 'Shipping zone setup complete. InPost zone ID: ' . $ps_zone->get_id(), 'info' );

		} catch ( Exception $e ) {
			parcselect_log( 'ps_auto_setup_shipping_zone failed: ' . $e->getMessage(), 'error' );
		} catch ( Error $e ) {
			parcselect_log( 'ps_auto_setup_shipping_zone error: ' . $e->getMessage(), 'error' );
		}
	}
}

// ============================================================================
// Map engine — ANUNAKI (live API) or Classic (local JSON files)
// ============================================================================

/**
 * Get the active map engine.
 * Classic engine is disabled — coordinates are served exclusively via ANUNAKI (live API).
 * Local JSON data/ files are not distributed with the plugin.
 *
 * @return string 'anunaki'
 */
if ( ! function_exists( 'parcselect_map_engine' ) ) {
function parcselect_map_engine() {
	return get_option( 'parcselect_map_engine', 'classic' );
}
}

// ============================================================================
// Scripts & Styles
// ============================================================================

add_action( 'wp_enqueue_scripts', 'parcselect_enqueue_frontend' );

if ( ! function_exists( 'parcselect_is_checkout_or_cart' ) ) {
function parcselect_is_checkout_or_cart() {
	// Standard WC check
	if ( is_cart() || is_checkout() ) return true;
	// WC 8.3+ Blocks: is_checkout() may return false on block-based checkout page
	if ( function_exists( 'wc_is_checkout' ) && wc_is_checkout() ) return true;
	// Fallback: check if current page contains the checkout block
	if ( function_exists( 'has_block' ) && ( has_block( 'woocommerce/checkout' ) || has_block( 'woocommerce/cart' ) ) ) return true;
	return false;
}
}

if ( ! function_exists( 'parcselect_enqueue_frontend' ) ) {
function parcselect_enqueue_frontend() {
	// Guard: WC must be fully initialized before calling is_cart/is_checkout
	if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
		return;
	}

	if ( ! parcselect_is_checkout_or_cart() ) {
		return;
	}

	$engine = parcselect_map_engine();

	// Vendor: Leaflet (always needed)
	wp_enqueue_style(
		'parcselect-leaflet',
		PARCSELECT_PLUGIN_URL . 'assets/vendor/leaflet/leaflet.css',
		array(),
		'1.9.4'
	);
	wp_enqueue_script(
		'parcselect-leaflet',
		PARCSELECT_PLUGIN_URL . 'assets/vendor/leaflet/leaflet.js',
		array(),
		'1.9.4',
		true
	);

	// Vendor: MarkerCluster
	wp_enqueue_style(
		'parcselect-markercluster',
		PARCSELECT_PLUGIN_URL . 'assets/vendor/markercluster/MarkerCluster.css',
		array( 'parcselect-leaflet' ),
		'1.5.3'
	);
	wp_enqueue_style(
		'parcselect-markercluster-default',
		PARCSELECT_PLUGIN_URL . 'assets/vendor/markercluster/MarkerCluster.Default.css',
		array( 'parcselect-markercluster' ),
		'1.5.3'
	);
	wp_enqueue_script(
		'parcselect-markercluster',
		PARCSELECT_PLUGIN_URL . 'assets/vendor/markercluster/leaflet.markercluster.js',
		array( 'parcselect-leaflet' ),
		'1.5.3',
		true
	);

	// Vendor: Fuse.js (fuzzy search)
	wp_enqueue_script(
		'parcselect-fuse',
		PARCSELECT_PLUGIN_URL . 'assets/vendor/fuse/fuse.min.js',
		array(),
		'7.0.0',
		true
	);

	// Mobile layer (Layer 14)
	wp_enqueue_script(
		'parcselect-mobile-detect',
		PARCSELECT_PLUGIN_URL . 'map/Mobilki/ps-mobile-detect.js',
		array(),
		'1.0.0',
		false // Load in head because it sets window.PS_MOBILE
	);
	wp_enqueue_style(
		'parcselect-mobile-override',
		PARCSELECT_PLUGIN_URL . 'map/Mobilki/ps-mobile-override.css',
		array( 'parcselect-leaflet' ),
		'1.0.0'
	);

	if ( $engine === 'classic' ) {
		parcselect_enqueue_classic_map();
	} else {
		parcselect_enqueue_anunaki_map();
	}

	// WC bridge (shared — listens to map events, sends to AJAX)
	// ps-wc-bridge only needs jQuery — remove ps-leaflet dependency
	// so bridge loads even if Leaflet fails (bridge ≠ map engine)
	wp_enqueue_script(
		'parcselect-wc-bridge',
		PARCSELECT_PLUGIN_URL . 'assets/js/ps-wc-bridge.js',
		array( 'jquery' ),
		PARCSELECT_VERSION,
		true
	);

	// Mobilki: karuzela flag (po mapach)
	// ps-mobile-flags depends on the active engine script (only one is loaded per page)
	if ( $engine === 'classic' ) {
		$_ps_flag_dep = 'parcselect-classic-intro';
	} else {
		$_ps_flag_dep = 'parcselect-anunaki-popup';
	}
	wp_enqueue_script(
		'parcselect-mobile-flags',
		PARCSELECT_PLUGIN_URL . 'map/Mobilki/ps-mobile-flags.js',
		array( 'parcselect-wc-bridge', $_ps_flag_dep ),
		'1.0.0',
		true
	);

	// Mobilki: gesty panelu (swipe-to-hide)
	wp_enqueue_script(
		'parcselect-mobile-panel',
		PARCSELECT_PLUGIN_URL . 'map/Mobilki/ps-mobile-panel.js',
		array( 'parcselect-mobile-detect' ),
		'1.0.0',
		true
	);

	$ps_config_data = array(
		'ajaxUrl'        => admin_url( 'admin-ajax.php' ),
		'adminUrl'       => admin_url( 'admin.php?page=parcelselector-settings' ),
		'nonce'          => wp_create_nonce( 'parcselect_ajax_nonce' ),
		'mapEngine'      => $engine,
		'pluginUrl'      => PARCSELECT_PLUGIN_URL,
		'defaultCountry' => get_option( 'parcselect_default_country' ) ?: 'pl',
		'debug'          => PARCSELECT_DEBUG ? 1 : 0,
		'countryNames'   => function_exists( 'parcselect_get_country_names_map' ) ? parcselect_get_country_names_map() : array(),
		'pluginVersion'  => PARCSELECT_VERSION,
		'i18n'           => array(
			'brutalTitle'       => __( 'Warning: No locker selected', 'parcel-selector' ),
			'brutalDesc'        => __( 'You selected InPost delivery, but your cart update hid the pickup point selector. Please select a pickup point to complete your order.', 'parcel-selector' ),
			'brutalBtn'         => __( 'Select Parcel Locker Now', 'parcel-selector' ),
			'selectLocker'      => __( 'Select parcel locker', 'parcel-selector' ),
			'selectedLocker'    => __( 'Selected parcel locker', 'parcel-selector' ),
			'changeLocker'      => __( 'Change parcel locker', 'parcel-selector' ),
			'loadingMap'        => __( 'Loading map...', 'parcel-selector' ),
			// Manual entry strings
			'manualToggleShow'  => __( "Can't see the map? Enter locker ID manually", 'parcel-selector' ),
			'manualToggleHide'  => __( 'Hide manual entry', 'parcel-selector' ),
			'manualLabel'       => __( 'Locker ID (e.g. WAW01A, KRA02B)', 'parcel-selector' ),
			'manualConfirm'     => __( 'Confirm', 'parcel-selector' ),
			'manualHint'        => __( 'You can find the locker ID on the InPost website or on the locker door.', 'parcel-selector' ),
			'manualEmpty'       => __( 'Please enter a locker ID.', 'parcel-selector' ),
			'manualTooShort'    => __( 'ID is too short — locker IDs are at least 3 characters (e.g. WAW01A).', 'parcel-selector' ),
			'manualSaving'      => __( 'Saving...', 'parcel-selector' ),
			'manualSaved'       => __( '✅ Locker saved!', 'parcel-selector' ),
		)
	);

	// Allows other classes (e.g. PS_Diagnostics) to inject additional parameters into JS.
	wp_localize_script( 'parcselect-wc-bridge', 'parcselectConfig', apply_filters( 'parcselect_frontend_config', $ps_config_data ) );

	// =========================================================================
	// Layer 11: Cache Protection (Anti-Minification & Anti-Defer)
	// =========================================================================
	add_filter('script_loader_tag', function($tag, $handle) {
		if ('parcselect-wc-bridge' !== $handle) {
			return $tag;
		}
		// Adds attributes forcing immediate file loading, bypassing Litespeed/Cloudflare optimization
		return str_replace(' src', ' data-cfasync="false" data-no-optimize="1" src', $tag);
	}, 10, 2);

	add_filter('rocket_exclude_js', function( $excluded_js ) {
		$excluded_js[] = 'assets/js/ps-wc-bridge.js';
		return $excluded_js;
	});

	add_filter('rocket_exclude_defer_js', function( $excluded_js ) {
		$excluded_js[] = 'assets/js/ps-wc-bridge.js';
		return $excluded_js;
	});

	// Inject custom pickup point config (shared for both engines)
	$_pickup = get_option( 'parcselect_custom_pickup', array() );
	$_p_lat  = (float) ( $_pickup['lat'] ?? 0 );
	$_p_lng  = (float) ( $_pickup['lng'] ?? 0 );
	if ( ! empty( $_pickup['enabled'] ) && $_pickup['enabled'] === 'yes' && $_p_lat && $_p_lng ) {
		$_pickup_json = wp_json_encode( array(
			'enabled'    => true,
			'name'       => $_pickup['name']   ?? '',
			'street'     => $_pickup['street'] ?? '',
			'city'       => $_pickup['city']   ?? '',
			'postal'     => $_pickup['postal'] ?? '',
			'country'    => $_pickup['country'] ?? 'PL',
			'lat'        => $_p_lat,
			'lng'        => $_p_lng,
			'desc'       => $_pickup['desc']   ?? '',
			'hours'      => $_pickup['hours']  ?? '',
			'label'      => $_pickup['label']  ?? 'Pick up your order locally',
			'zoom_mode'  => $_pickup['zoom_mode'] ?? 'from_europe',
			'show_first' => ( $_pickup['show_first'] ?? 'no' ) === 'yes',
			'highlight'  => ( $_pickup['highlight'] ?? 'yes' ) === 'yes',
		) );
		// Attach to the active engine's main script handle
		if ( $engine === 'classic' ) {
			$_handle = 'parcselect-classic-intro';
		} else {
			$_handle = 'parcselect-anunaki-popup';
		}
		wp_add_inline_script( $_handle, 'window.PS_CUSTOM_PICKUP=' . $_pickup_json . ';' );
	}
}
}

/**
 * Enqueue ANUNAKI map engine assets.
 */
if ( ! function_exists( 'parcselect_enqueue_anunaki_map' ) ) {
function parcselect_enqueue_anunaki_map() {
	wp_enqueue_style(
		'parcselect-anunaki-popup',
		PARCSELECT_PLUGIN_URL . 'map/anunaki/css/anunaki-popup.css',
		array( 'parcselect-leaflet' ),
		parcselect_file_version( PARCSELECT_PLUGIN_DIR . 'map/anunaki/css/anunaki-popup.css' )
	);
	wp_enqueue_script(
		'parcselect-anunaki-countries',
		PARCSELECT_PLUGIN_URL . 'map/anunaki/js/anunaki-countries.js',
		array(),
		parcselect_file_version( PARCSELECT_PLUGIN_DIR . 'map/anunaki/js/anunaki-countries.js' ),
		true
	);

	// Patch ANUNAKI_COUNTRIES.api URLs → WordPress AJAX (WP.org compliant: no hardcoded external URLs in JS).
	// The inline script runs immediately after anunaki-countries.js and replaces every api URL
	// with an admin-ajax.php endpoint that proxies locker data (see parcselect_ajax_get_points()).
	$api_base = admin_url( 'admin-ajax.php' ) . '?action=parcselect_get_points&nonce=' . wp_create_nonce( 'parcselect_points_public' ) . '&country=';

	/* Free version scope: Poland only, InPost only. */
	$_ps_allowed_countries = array( 'pl' );
	$_ps_carriers          = array( 'inpost' );

	// Patch ANUNAKI_COUNTRIES.api URLs → WordPress AJAX (after countries.js loads).
	wp_add_inline_script( 'parcselect-anunaki-countries',
		'(function(){var c=window.ANUNAKI_COUNTRIES||{};var b=' . wp_json_encode( $api_base ) . ';Object.keys(c).forEach(function(cc){c[cc].api=b+cc;});})();'
	);

	wp_localize_script( 'parcselect-anunaki-countries', 'parcselectAnunakiConfig', array(
		'apiBase'           => $api_base,
		'allowedCountries'  => $_ps_allowed_countries,
		'logoUrls'        => array(
			'inpost' => PARCSELECT_PLUGIN_URL . 'assets/Speditors/InPost.png',
		),
		'enabledCarriers'  => array( 'inpost' ), // FREE — hardcoded
		'showLegend'       => get_option( 'parcselect_show_legend', 'yes' ),
		'showPointTypes'   => get_option( 'parcselect_show_point_types', 'yes' ),
		'isAdminPreview'   => is_admin() ? 'yes' : 'no',
		'flagBaseW20'      => PARCSELECT_PLUGIN_URL . 'assets/flags/20x15/',
		'flagBaseW40'      => PARCSELECT_PLUGIN_URL . 'assets/flags/w40/',
		'mascotUrl'        => PARCSELECT_PLUGIN_URL . 'assets/images/Parcel_Selector_Com.jpg',
	) );

	$suffix = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';

	wp_enqueue_script(
		'parcselect-anunaki-translations',
		PARCSELECT_PLUGIN_URL . 'map/anunaki/js/anunaki-translations.js',
		array(),
		PARCSELECT_VERSION,
		true
	);
	wp_enqueue_script(
		'parcselect-anunaki-search',
		PARCSELECT_PLUGIN_URL . 'map/anunaki/js/anunaki-search.js',
		array( 'parcselect-fuse' ),
		PARCSELECT_VERSION,
		true
	);
	wp_enqueue_script(
		'ps-carrier-logos',
		PARCSELECT_PLUGIN_URL . 'assets/Speditors/carrier-logos.js',
		array(),
		PARCSELECT_VERSION,
		true
	);
	// Cluster behavior rules — must load before anunaki-popup
	wp_enqueue_script(
		'ps-cluster-rules',
		PARCSELECT_PLUGIN_URL . 'map/anunaki/js/anunaki-cluster-rules.js',
		array(),
		parcselect_file_version( PARCSELECT_PLUGIN_DIR . 'map/anunaki/js/anunaki-cluster-rules.js' ),
		true
	);
	wp_enqueue_script(
		'parcselect-anunaki-popup',
		PARCSELECT_PLUGIN_URL . "map/anunaki/js/anunaki-popup{$suffix}.js",
		array( 'parcselect-leaflet', 'parcselect-markercluster', 'parcselect-anunaki-countries', 'parcselect-anunaki-translations', 'parcselect-anunaki-search', 'ps-carrier-logos', 'ps-cluster-rules' ),
		parcselect_file_version( PARCSELECT_PLUGIN_DIR . "map/anunaki/js/anunaki-popup{$suffix}.js" ),
		true
	);

}
}


/**
 * Enqueue Classic (INTRO_1) map engine assets.
 */
if ( ! function_exists( 'parcselect_enqueue_classic_map' ) ) {
function parcselect_enqueue_classic_map() {
	wp_enqueue_style(
		'parcselect-classic-intro',
		PARCSELECT_PLUGIN_URL . 'map/classic/css/intro-popup.css',
		array( 'parcselect-leaflet' ),
		PARCSELECT_VERSION
	);
	wp_enqueue_style(
		'parcselect-classic-feedback',
		PARCSELECT_PLUGIN_URL . 'map/classic/css/inpost-visual-feedback.css',
		array(),
		PARCSELECT_VERSION
	);
	wp_enqueue_script(
		'parcselect-classic-countries',
		PARCSELECT_PLUGIN_URL . 'map/classic/js/countries.js',
		array(),
		PARCSELECT_VERSION,
		true
	);
	wp_enqueue_script(
		'parcselect-classic-translations',
		PARCSELECT_PLUGIN_URL . 'map/classic/js/translations.js',
		array( 'parcselect-classic-countries' ),
		PARCSELECT_VERSION,
		true
	);
	wp_enqueue_script(
		'parcselect-classic-search',
		PARCSELECT_PLUGIN_URL . 'map/classic/js/search-engine.js',
		array( 'parcselect-fuse', 'parcselect-classic-countries' ),
		PARCSELECT_VERSION,
		true
	);
	// intro-popup.js is the core Classic map engine — must load before parcel-locker-selector.js
	wp_enqueue_script(
		'parcselect-classic-intro',
		PARCSELECT_PLUGIN_URL . 'map/classic/js/intro-popup.js',
		array( 'parcselect-leaflet', 'parcselect-markercluster', 'parcselect-classic-countries', 'parcselect-classic-translations', 'parcselect-classic-search' ),
		PARCSELECT_VERSION,
		true
	);
	// Pass the AJAX API base to JS for all countries
	$classic_api_base = admin_url( 'admin-ajax.php' ) . '?action=parcselect_get_points&nonce=' . wp_create_nonce( 'parcselect_points_public' ) . '&country=';
	wp_localize_script( 'parcselect-classic-countries', 'parcselectClassicConfig', array(
		'apiBase' => $classic_api_base
	) );
	// Output parcselectCacheConfig — read by intro-popup.js for epicZoom and debug flags.
	$beh_opts       = get_option( 'parcselect_paczkomaty_options', array() );
	$epic_zoom      = ! empty( $beh_opts['parcselect_epic_zoom'] ) && $beh_opts['parcselect_epic_zoom'] === 'yes';
	$cache_config   = array(
		'enabled'         => true,
		'epicZoomEnabled' => $epic_zoom,
		'debug'           => PARCSELECT_DEBUG,
		'countryModes'    => array(), // empty = all countries use IPS_COUNTRIES.api URL (already patched above)
		'nonce'           => wp_create_nonce( 'parcselect_ajax_nonce' ),
	);
	wp_localize_script( 'parcselect-classic-intro', 'parcselectCacheConfig', $cache_config );

	wp_enqueue_script(
		'parcselect-classic-popup',
		PARCSELECT_PLUGIN_URL . 'map/classic/js/parcel-locker-selector.js',
		array( 'parcselect-classic-intro' ),
		PARCSELECT_VERSION,
		true
	);
	wp_enqueue_script(
		'parcselect-classic-feedback',
		PARCSELECT_PLUGIN_URL . 'map/classic/js/inpost-visual-feedback.js',
		array( 'parcselect-classic-popup' ),
		PARCSELECT_VERSION,
		true
	);
}
}

// ============================================================================
// AJAX: Serve locker point data (both map engines use this)
// Both modes fetch directly from parcelselector.com API via wp_remote_get()
// Local JSON lists and the older data/ directory are permanently deprecated
// ============================================================================

// v4.4.5 audyt #4 — bezposrednia rejestracja parcselect_ajax_get_points USUNIETA.
// Hook 'parcselect_get_points' byl rejestrowany DWUKROTNIE (bezposrednio + alias),
// przez co handler wykonywal sie 2x. Pozostaje wylacznie alias ponizej, ktory
// deleguje do parcselect_ajax_get_points() — handler dalej jest osiagalny.

// Back-compat aliases: theme still hardcodes JS from /plugins/parcel-selector/ (inactive PRO)
// whose anunaki-popup.js calls legacy action `ps_get_points` with legacy nonce
// `ps_ajax_nonce`. The new handler only accepts parcselect_ajax_nonce/parcselect_points_public,
// so the direct alias would fail check_ajax_referer. This wrapper accepts both naming
// conventions then delegates.
if ( ! function_exists( 'parcselect_ajax_get_points_legacy_alias' ) ) {
	function parcselect_ajax_get_points_legacy_alias() {
		$_nonce = isset( $_REQUEST['nonce'] ) ? sanitize_key( wp_unslash( $_REQUEST['nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
		if ( wp_verify_nonce( $_nonce, 'ps_ajax_nonce' )
			|| wp_verify_nonce( $_nonce, 'ps_points_public' ) ) {
			// Re-sign with the new-convention nonce so check_ajax_referer inside the
			// handler succeeds without rewriting its logic.
			// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- zapis swiezo wygenerowanego nonce do superglobali, nie odczyt danych uzytkownika.
			$_REQUEST['nonce'] = wp_create_nonce( 'parcselect_ajax_nonce' );
			$_POST['nonce']    = $_REQUEST['nonce'];
			$_GET['nonce']     = $_REQUEST['nonce'];
			// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		}
		parcselect_ajax_get_points();
	}
}
add_action( 'wp_ajax_parcselect_get_points',        'parcselect_ajax_get_points_legacy_alias' );
add_action( 'wp_ajax_nopriv_parcselect_get_points', 'parcselect_ajax_get_points_legacy_alias' );


/**
 * AJAX handler — returns parcel locker points for a country.
 * Coordinates are served exclusively from parcelselector.com API (ANUNAKI).
 * Local data files are not distributed with the plugin.
 */
if ( ! function_exists( 'parcselect_ajax_get_points' ) ) {
function parcselect_ajax_get_points() {
	// Carrier data for 9 carriers × 100k+ PL points can exceed 256MB default limit.
	wp_raise_memory_limit( 'admin' );

	// ── HARDENING: never emit HTTP 500 from this endpoint ────────────────────
	// A 500 here breaks the cart map silently (the JS sees an opaque failure
	// and displays the red "Map loading issue detected" banner). Any fatal
	// or uncaught exception in the fetch/decode path below is converted into
	// a clean JSON `[]` response so the widget renders empty (zero lockers)
	// instead of crashing the whole map stack.
	$ps_points_fatal_guard = function () {
		$err = error_get_last();
		if ( $err && in_array( $err['type'], array( E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_PARSE ), true ) ) {
			if ( ! headers_sent() ) {
				while ( ob_get_level() ) { @ob_end_clean(); } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				status_header( 200 );
				header( 'Content-Type: application/json; charset=utf-8' );
				echo '[]';
			}
			if ( function_exists( 'error_log' ) ) {
				error_log( 'parcselect_get_points fatal: ' . $err['message'] . ' @ ' . $err['file'] . ':' . $err['line'] ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie bledu krytycznego do diagnostyki.
			}
		}
	};
	register_shutdown_function( $ps_points_fatal_guard );

	// Accept dedicated public nonce (isolated from admin nonce) or legacy nonce for compat.
	$_nonce = sanitize_key( isset( $_REQUEST['nonce'] ) ? $_REQUEST['nonce'] : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
	if ( ! wp_verify_nonce( $_nonce, 'parcselect_points_public' ) ) {
		check_ajax_referer( 'parcselect_ajax_nonce', 'nonce' );
	}

	try {


	$country = sanitize_key( isset( $_GET['country'] ) ? $_GET['country'] : ( isset( $_POST['country'] ) ? $_POST['country'] : '' ) );
	$mode    = sanitize_key( isset( $_GET['mode'] )    ? $_GET['mode']    : '' );

	if ( empty( $country ) || ! preg_match( '/^[a-z]{2}$/', $country ) ) {
		wp_send_json_error( array( 'message' => 'Invalid country code.' ), 400 );
	}
	// FREE — only PL is supported, all other country codes return empty list.
	if ( 'pl' !== $country ) {
		wp_send_json( array() );
	}

	// ── Fetch InPost points. VPS serves /api/points/{cc}. ─────────────────────
	// FREE — InPost only, hardcoded (Poland). No multi-carrier or route logic.
	$ps_enabled_carriers = array( 'inpost' );
	$ps_valid_carriers   = array( 'inpost' );

	// ── Single-carrier mode: ?carrier=inpost returns only that carrier's data.
	// JS fires N parallel requests (one per carrier) for maximum speed.
	// Without ?carrier, all enabled carriers are returned (backward compat).
	$ps_single_carrier = sanitize_key( isset( $_GET['carrier'] ) ? $_GET['carrier'] : '' );
	if ( ! empty( $ps_single_carrier ) ) {
		if ( ! in_array( $ps_single_carrier, $ps_valid_carriers, true ) ) {
			wp_send_json_error( array( 'message' => 'Invalid carrier.' ), 400 );
		}
		if ( ! in_array( $ps_single_carrier, $ps_enabled_carriers, true ) ) {
			wp_send_json( array() ); // carrier disabled — empty response
			return;
		}
		$ps_enabled_carriers = array( $ps_single_carrier ); // fetch only this one
	}

	// MEMORY-SAFE BATCH — compute a hard byte budget before we start
	// accumulating. Prevents the historic 142 MB peak when the cache is
	// cold for all 8 carriers on memory_limit=128M hosts. When the budget
	// is exceeded we stop appending and return partial data with a header
	// signalling the client to fall back to per-carrier requests.
	$_mem_limit_bytes = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) );
	if ( $_mem_limit_bytes <= 0 ) { $_mem_limit_bytes = 128 * 1024 * 1024; }
	// Keep 40% headroom for WP core + WC + other plugins on the same request.
	$_mem_budget_bytes = (int) ( $_mem_limit_bytes * 0.40 );

	$all_points      = array();
	$installation_id = get_option( 'parcselect_installation_id', '' );

	foreach ( $ps_enabled_carriers as $ps_carrier ) {
		$ps_carrier = sanitize_key( $ps_carrier );
		if ( ! in_array( $ps_carrier, $ps_valid_carriers, true ) ) {
			continue;
		}

		$ps_tc_key = 'parcselect_points_' . $ps_carrier . '_' . $country;
		// Compressed transient first (gzip+serialize, ~70% smaller in wp_options).
		$ps_cdata_compressed = get_transient( $ps_tc_key . '_gz' );
		$ps_cdata = false;
		if ( is_string( $ps_cdata_compressed ) && '' !== $ps_cdata_compressed ) {
			$_decoded = @gzuncompress( $ps_cdata_compressed ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- corrupted cache should fall through to refetch, not fatal.
			if ( false !== $_decoded ) {
				$_un = @unserialize( $_decoded ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( is_array( $_un ) ) {
					$ps_cdata = $_un;
				}
			}
			unset( $_decoded, $_un );
		}
		if ( false === $ps_cdata ) {
			// Legacy uncompressed transient — read once, will be replaced below.
			$ps_cdata = get_transient( $ps_tc_key );
		}

		// Stale-while-revalidate snapshot — served if VPS is slow/down.
		$ps_stale_snapshot = get_option( $ps_tc_key . '_stale', false );

		if ( false === $ps_cdata || ! is_array( $ps_cdata ) ) {
			// Priority 2: remote API — used in production (Linux VPS loopback, fast).
			$ps_mutex = 'parcselect_fetching_' . $ps_carrier . '_' . $country;
			if ( get_transient( $ps_mutex ) ) {
				// Another process is fetching this carrier — serve stale if we have it.
				if ( is_array( $ps_stale_snapshot ) ) {
					$ps_cdata = $ps_stale_snapshot;
				} else {
					continue;
				}
			} else {
				set_transient( $ps_mutex, 1, 15 );

				$ps_api_url = PARCSELECT_API_BASE . '/api/points/' . $country;

				$ps_response = wp_remote_get( $ps_api_url, array(
					// Timeout 5s — was 15s. PHP-FPM worker no longer blocks
					// for 15s when VPS is slow; falls back to stale snapshot.
					'timeout'   => 5,
					'sslverify' => apply_filters( 'parcselect_api_sslverify', true ),
					'headers'   => array(
						'Accept'            => 'application/json',
						'X-Installation-Id' => $installation_id,
					),
				) );

				delete_transient( $ps_mutex );

				if ( is_wp_error( $ps_response ) ) {
					// Stale-while-revalidate: serve old snapshot rather than empty.
					if ( is_array( $ps_stale_snapshot ) ) {
						$ps_cdata = $ps_stale_snapshot;
					} else {
						continue;
					}
				} else {
					$ps_code = wp_remote_retrieve_response_code( $ps_response );
					if ( 403 === (int) $ps_code ) {
						wp_send_json_error( array( 'message' => 'License access denied.', 'code' => 'ACCESS_DENIED' ), 403 );
					}
					if ( 200 !== (int) $ps_code ) {
						if ( is_array( $ps_stale_snapshot ) ) {
							$ps_cdata = $ps_stale_snapshot;
						} else {
							continue;
						}
					} else {
						$ps_body  = wp_remote_retrieve_body( $ps_response );
						$ps_cdata = json_decode( $ps_body, true );
						unset( $ps_body );
						if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $ps_cdata ) ) {
							if ( is_array( $ps_stale_snapshot ) ) {
								$ps_cdata = $ps_stale_snapshot;
							} else {
								continue;
							}
						} else {
							// Save compressed primary cache (6h) + uncompressed
							// stale fallback (30 days, no autoload) so a slow
							// VPS never empties the map.
							$_serialized = serialize( $ps_cdata );
							$_compressed = gzcompress( $_serialized, 6 );
							if ( false !== $_compressed ) {
								set_transient( $ps_tc_key . '_gz', $_compressed, 6 * HOUR_IN_SECONDS );
								unset( $_compressed );
							}
							unset( $_serialized );
							update_option( $ps_tc_key . '_stale', $ps_cdata, false );
							// Drop legacy uncompressed transient if present.
							delete_transient( $ps_tc_key );
						}
					}
				}
			}
		}

		if ( is_array( $ps_cdata ) ) {
			// Ensure every point carries its carrier slug and correct country code.
			// Without this, JS falls back to defaultCountry and orders get wrong _ps_locker_country.
			foreach ( $ps_cdata as &$_ps_pt ) {
				if ( ! isset( $_ps_pt['carrier'] ) || empty( $_ps_pt['carrier'] ) ) {
					$_ps_pt['carrier'] = $ps_carrier;
				}
				if ( ! isset( $_ps_pt['country'] ) || empty( $_ps_pt['country'] ) ) {
					$_ps_pt['country'] = strtoupper( $country );
				}
			}
			unset( $_ps_pt );

			// Deduplicate same-carrier points within ~11m of each other.
			// 4 decimal places = ~11m precision — catches stations with two APMs
			// at the same address but slightly different GPS (common geocoding issue).
			$_ps_seen    = array();
			$_ps_deduped = array();
			foreach ( $ps_cdata as $_ps_pt ) {
				$_lat = isset( $_ps_pt['lat'] ) ? round( (float) $_ps_pt['lat'], 4 ) : null;
				$_lng = isset( $_ps_pt['lng'] ) ? round( (float) $_ps_pt['lng'], 4 ) : null;
				if ( null === $_lat || null === $_lng ) { continue; }
				$_ck = $_lat . ',' . $_lng;
				if ( ! isset( $_ps_seen[ $_ck ] ) ) {
					$_ps_seen[ $_ck ] = true;
					$_ps_deduped[]    = $_ps_pt;
				}
			}
			unset( $_ps_seen );
			$all_points = array_merge( $all_points, $_ps_deduped );
			unset( $_ps_deduped, $ps_cdata, $ps_stale_snapshot );
		}

		// MEMORY GUARD — abort the carrier loop early if we're already
		// burning through the budget. Returns partial response with an
		// X-PS-Partial header so the client can fall back to per-carrier
		// fan-out for the missing ones rather than the cart map crashing.
		if ( memory_get_usage( true ) > $_mem_budget_bytes ) {
			header( 'X-PS-Partial: 1' );
			header( 'X-PS-Partial-Reason: memory_budget_reached' );
			break;
		}
	}

	// ── Preview mode: ~200 geographically spread points for instant first paint ──
	if ( 'preview' === $mode ) {
		if ( function_exists( 'parcselect_points_preview_sample' ) ) {
			$all_points = parcselect_points_preview_sample( $all_points, 200 );
		}
	}

	wp_send_json( $all_points );

	} catch ( \Throwable $e ) {
		// Last-resort soft-fail: log the fatal and return empty JSON so the
		// map widget renders "no lockers for this country" instead of
		// bubbling up HTTP 500 into the customer's cart.
		if ( function_exists( 'error_log' ) ) {
			error_log( 'parcselect_get_points exception: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- logowanie wyjatku do diagnostyki.
		}
		if ( ! headers_sent() ) {
			status_header( 200 );
			header( 'Content-Type: application/json; charset=utf-8' );
		}
		echo '[]';
		exit;
	}
}
}


/**
 * AJAX handler — reports JS/Map crashes to the VPS API.
 * Triggered when scripts fail to load or global errors occur in map modules.
 */
add_action( 'wp_ajax_parcselect_report_crash',        'parcselect_ajax_report_crash' );
add_action( 'wp_ajax_nopriv_parcselect_report_crash', 'parcselect_ajax_report_crash' );

if ( ! function_exists( 'parcselect_ajax_report_crash' ) ) {
function parcselect_ajax_report_crash() {
	check_ajax_referer( 'parcselect_ajax_nonce', 'nonce' );

	// Rate limit: max 3 crash reports per IP per hour.
	$_ip   = sanitize_text_field( isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
	$_rkey = 'parcselect_crash_' . md5( $_ip );
	$_rcnt = (int) get_transient( $_rkey );
	if ( $_rcnt >= 3 ) {
		wp_send_json_error( array( 'message' => 'Rate limited' ), 429 );
	}
	set_transient( $_rkey, $_rcnt + 1, HOUR_IN_SECONDS );

	$error_msg = sanitize_textarea_field( wp_unslash( $_POST['error'] ?? 'Unknown Error' ) );
	$context   = sanitize_text_field( wp_unslash( $_POST['context'] ?? 'General' ) );
	$engine    = sanitize_text_field( wp_unslash( $_POST['engine'] ?? parcselect_map_engine() ) );

	// v4.2 fix: opt-in toggle + class-based base URL (no hardcoded leak)
	// Klient musi explicit enable w wp_options 'parcselect_crash_reports_enabled' = '1'
	if ( '1' !== (string) get_option( 'parcselect_crash_reports_enabled', '0' ) ) {
		wp_send_json_success( array( 'sent' => false, 'reason' => 'crash_reports_disabled' ) );
	}
	$_base = class_exists( 'Parcselect_Oracle_Client' ) ? Parcselect_Oracle_Client::base() : 'https://parcelselector.com';
	$api_url         = trailingslashit( $_base ) . 'api/report-error';
	$installation_id = get_option( 'parcselect_installation_id', '' );

	$response = wp_remote_post( $api_url, array(
		'timeout' => 10,
		'body'    => wp_json_encode( array(
			'installation_id' => $installation_id,
			'url'             => home_url( '/' ),
			'error'           => $error_msg,
			'context'         => $context,
			'engine'          => $engine,
			'timestamp'       => current_time( 'mysql' ),
			'ua'              => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'Unknown',
		) ),
		'headers' => array(
			'Content-Type' => 'application/json',
			'Accept'       => 'application/json',
		),
	) );

	if ( is_wp_error( $response ) ) {
		wp_send_json_error( array( 'message' => 'Reporting failed' ) );
	}

	wp_send_json_success( array( 'reported' => true ) );
}
}

/**
 * Returns ~$limit geographically spread points using a lat/lng grid.
 * Guarantees good visual coverage across the whole country on first paint.
 *
 * @param array $points Full points array from API.
 * @param int   $limit  Max points to return (default 200).
 * @return array
 */
if ( ! function_exists( 'parcselect_points_preview_sample' ) ) {
function parcselect_points_preview_sample( $points, $limit = 200 ) {
	if ( ! is_array( $points ) || count( $points ) <= $limit ) {
		return $points;
	}

	// Find actual bounds of this dataset
	$min_lat = 90.0; $max_lat = -90.0;
	$min_lng = 180.0; $max_lng = -180.0;
	foreach ( $points as $p ) {
		if ( empty( $p['lat'] ) || empty( $p['lng'] ) ) continue;
		$lat = (float) $p['lat']; $lng = (float) $p['lng'];
		if ( $lat < $min_lat ) $min_lat = $lat;
		if ( $lat > $max_lat ) $max_lat = $lat;
		if ( $lng < $min_lng ) $min_lng = $lng;
		if ( $lng > $max_lng ) $max_lng = $lng;
	}

	$lat_range = max( $max_lat - $min_lat, 0.01 );
	$lng_range = max( $max_lng - $min_lng, 0.01 );

	// 14×14 = 196 grid cells → one point per cell = ~200 geographically spread points
	$grid_n = 14;
	$grid   = array();
	foreach ( $points as $p ) {
		if ( empty( $p['lat'] ) || empty( $p['lng'] ) ) continue;
		$row = (int) floor( ( (float) $p['lat'] - $min_lat ) / $lat_range * $grid_n );
		$col = (int) floor( ( (float) $p['lng'] - $min_lng ) / $lng_range * $grid_n );
		$row = max( 0, min( $grid_n - 1, $row ) );
		$col = max( 0, min( $grid_n - 1, $col ) );
		$key = $row . '_' . $col;
		if ( ! isset( $grid[ $key ] ) ) {
			$grid[ $key ] = $p;
		}
	}

	$preview = array_values( $grid );

	// Fill up to $limit with random extras if grid has fewer than $limit cells
	if ( count( $preview ) < $limit ) {
		$in_preview = array();
		foreach ( $preview as $p ) {
			$in_preview[ $p['lat'] . '_' . $p['lng'] ] = true;
		}
		$extras = array();
		foreach ( $points as $p ) {
			if ( empty( $p['lat'] ) || empty( $p['lng'] ) ) continue;
			if ( ! isset( $in_preview[ $p['lat'] . '_' . $p['lng'] ] ) ) {
				$extras[] = $p;
			}
		}
		shuffle( $extras );
		$preview = array_merge( $preview, array_slice( $extras, 0, $limit - count( $preview ) ) );
	}

	return array_slice( $preview, 0, $limit );
}
}

/**
 * Allowed HTML tags for server_notice html_content field.
 * Restricted set — safe for rendering in admin context.
 */
if ( ! function_exists( 'parcselect_allowed_notice_html' ) ) {
function parcselect_allowed_notice_html() {
	// Hardened 2026-05-17: no style attribute anywhere - server-supplied notice
	// HTML must not be able to inject CSS overlays into wp-admin.
	return array(
		'strong' => array(),
		'em'     => array(),
		'b'      => array(),
		'i'      => array(),
		'br'     => array(),
		'p'      => array(),
		'a'      => array( 'href' => array(), 'target' => array(), 'rel' => array() ),
		'span'   => array(),
		'div'    => array(),
		'img'    => array( 'src' => array(), 'alt' => array(), 'width' => array(), 'height' => array() ),
		'ul'     => array(),
		'ol'     => array(),
		'li'     => array(),
		'hr'     => array(),
		'small'  => array(),
	);
}
}

// v4.4.5 audyt #13 — duplikat parcselect_render_server_notice() USUNIETY.
// Funkcja byla zdefiniowana dwukrotnie (tu + WOOCOMMERCE_ORDER/order-render-utils.php).
// Kanoniczna wersja zostaje w order-render-utils.php — wezszy whitelist KSES
// (parcselect_allowed_notice_html) zamiast wp_kses_post, hardening 4.4.5.
// order-render-utils.php jest pierwszym ladowanym plikiem modulu WOOCOMMERCE_ORDER
// (parcselect_init, gdy WooCommerce aktywne); funkcja jest wolana wylacznie na
// ekranach zamowien WC, gdzie ten plik jest zawsze obecny.

// Server notice display — on WooCommerce order (admin order edit page)
if ( ! function_exists( 'parcselect_register_order_notice_metabox' ) ) {
function parcselect_register_order_notice_metabox() {
	$notice = get_option( 'parcselect_server_notice', null );
	if ( empty( $notice ) || empty( $notice['text'] ) ) return;

	$screens = array( 'shop_order', 'woocommerce_page_wc-orders' );
	foreach ( $screens as $screen ) {
		add_meta_box(
			'ps-server-notice-metabox',
			'&#x1F4E6; ParcelSelector.com',
			'parcselect_render_order_notice_metabox',
			$screen,
			'side',
			'high'
		);
	}
}
}
if ( ! function_exists( 'parcselect_render_order_notice_metabox' ) ) {
function parcselect_render_order_notice_metabox() {
	parcselect_render_server_notice( 'wc_order' );
}
}

// ============================================================================
// Admin settings page
// ============================================================================

add_action( 'admin_menu', 'parcselect_admin_menu' );

// Colored parcel icon for the ParcelSelector admin menu entry.
// Hooked on admin_enqueue_scripts (NOT admin_head): wp_add_inline_style must run
// BEFORE the 'wp-admin' stylesheet is printed. On admin_head it fires after the
// styles are already flushed, so the inline CSS was dropped and the orange icon
// disappeared from the menu.
add_action( 'admin_enqueue_scripts', function() {
	// Orange parcel icon in admin sidebar — attached via wp_add_inline_style on
	// the always-present 'wp-admin' style handle so we never emit a raw <style>
	// block from PHP.
	$_ps_icon_svg = rawurlencode(
		'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">'
		. '<path d="M12 2L3 7v10l9 5 9-5V7l-9-5z" fill="#f59e0b"/>'
		. '<path d="M12 2L3 7l9 5 9-5-9-5z" fill="#fbbf24"/>'
		. '<path d="M12 12v10l9-5V7l-9 5z" fill="#d97706"/>'
		. '<path d="M12 12L3 7v10l9 5V12z" fill="#f59e0b"/>'
		. '<line x1="12" y1="12" x2="12" y2="22" stroke="#fbbf24" stroke-width=".5"/>'
		. '<line x1="3" y1="7" x2="12" y2="12" stroke="#fde68a" stroke-width=".3"/>'
		. '<path d="M7.5 4.5L16.5 9.5" stroke="#fef3c7" stroke-width=".5"/>'
		. '</svg>'
	);
	$_ps_icon_css = '#adminmenu .toplevel_page_parcelselector-settings .wp-menu-image{'
		. 'background-image:url("data:image/svg+xml,' . $_ps_icon_svg . '")!important;'
		. 'background-repeat:no-repeat!important;'
		. 'background-position:center center!important;'
		. 'background-size:22px 22px!important;'
		. '}'
		. '#adminmenu .toplevel_page_parcelselector-settings .wp-menu-image::before{content:none!important;}'
		. '#adminmenu .toplevel_page_parcelselector-settings .wp-menu-image img{display:none!important;}';
	wp_add_inline_style( 'wp-admin', $_ps_icon_css );
} );

if ( ! function_exists( 'parcselect_admin_menu' ) ) {
function parcselect_admin_menu() {
	/* WP.org FREE — no order limit badge */
	$_ps_menu_badge  = '';

	// Top-level WordPress admin sidebar menu (colored icon via CSS, position after WooCommerce).
	// Icon = 'none' so WP creates empty div — CSS background-image fills it with colored SVG.
	// This is the ONLY method that survives WP's JS desaturation of <img> tags.
	add_menu_page(
		__( 'Parcel Selector', 'parcel-selector' ),
		'Parcel Selector' . $_ps_menu_badge,
		'manage_options',
		'parcelselector-settings',
		'parcselect_admin_panel_render',
		'none',
		56
	);

	// Primary page — WooCommerce submenu (slug: parcelselector-settings)
	add_submenu_page(
		'woocommerce',
		'Parcel Selector',
		'Parcel Selector' . $_ps_menu_badge,
		'manage_options',
		'parcelselector-settings',
		'parcselect_admin_panel_render'
	);

	// Alias 1: parcel-selector-settings (with hyphens) — backward compat
	add_submenu_page(
		null, // hidden
		__( 'Parcel Selector Settings', 'parcel-selector' ),
		__( 'Parcel Selector', 'parcel-selector' ),
		'manage_options',
		'parcel-selector-settings',
		'parcselect_admin_panel_render'
	);

	// Alias 2: parcelselector (no hyphens) — old bookmarks / legacy links
	add_submenu_page(
		null, // hidden
		__( 'Parcel Selector', 'parcel-selector' ),
		__( 'Parcel Selector', 'parcel-selector' ),
		'manage_options',
		'parcelselector',
		'parcselect_admin_panel_render'
	);

	// Alias 3: inpost_paczkomaty_settings — legacy slug from old plugin versions
	add_submenu_page(
		null, // hidden
		__( 'Parcel Selector', 'parcel-selector' ),
		__( 'Parcel Selector', 'parcel-selector' ),
		'manage_options',
		'inpost_paczkomaty_settings',
		'parcselect_admin_panel_render'
	);

}
}

if ( ! function_exists( 'parcselect_redirect_legacy_parcelselector' ) ) {
function parcselect_redirect_legacy_parcelselector() {
	wp_safe_redirect( admin_url( 'admin.php?page=parcelselector-settings' ) );
	exit;
}
}


add_action( 'admin_init', 'parcselect_register_settings' );

if ( ! function_exists( 'parcselect_register_settings' ) ) {
function parcselect_register_settings() {
	register_setting( 'parcselect_settings_group', 'parcselect_map_engine', array(
		'sanitize_callback' => function ( $val ) {
			return in_array( $val, array( 'anunaki', 'classic' ), true ) ? $val : 'anunaki';
		},
	) );
	register_setting( 'parcselect_settings_group', 'parcselect_default_country', array(
		'sanitize_callback' => 'sanitize_key',
	) );
	register_setting( 'parcselect_settings_group', 'parcselect_show_powered_by', array(
		'sanitize_callback' => function ( $val ) {
			return $val === 'yes' ? 'yes' : 'no';
		},
		'default' => 'no',
	) );
	// Origin country — separate group to avoid collision with engine form.
	register_setting( 'parcselect_origin_group', 'parcselect_shop_origin_country', array(
		'sanitize_callback' => function ( $val ) {
			$val = strtoupper( sanitize_key( $val ) );
			return preg_match( '/^[A-Z]{2}$/', $val ) ? $val : 'PL';
		},
	) );

	// Behavior tab settings — stored as a serialized array in parcselect_paczkomaty_options
	register_setting( 'parcselect_behavior_group', 'parcselect_paczkomaty_options', array(
		'sanitize_callback' => 'parcselect_sanitize_behavior_options',
	) );
	register_setting( 'parcselect_behavior_group', 'parcselect_custom_pickup', array(
		'sanitize_callback' => 'parcselect_sanitize_custom_pickup',
	) );

	// Carriers tab settings
	register_setting( 'parcselect_carriers_group', 'parcselect_enabled_carriers', array(
		'sanitize_callback' => 'parcselect_sanitize_enabled_carriers',
	) );
	register_setting( 'parcselect_carriers_group', 'parcselect_show_legend', array(
		'sanitize_callback' => function ( $val ) {
			return 'yes' === $val ? 'yes' : 'no';
		},
	) );
	register_setting( 'parcselect_carriers_group', 'parcselect_show_point_types', array(
		'sanitize_callback' => function ( $val ) {
			return 'yes' === $val ? 'yes' : 'no';
		},
	) );
}
}

/**
 * Get the shop's origin country (where parcels are shipped FROM).
 * Falls back to WooCommerce store country, then 'PL'.
 *
 * @return string  Uppercase 2-letter country code.
 */
if ( ! function_exists( 'parcselect_get_shop_origin' ) ) {
function parcselect_get_shop_origin() {
	$origin = get_option( 'parcselect_shop_origin_country', '' );
	if ( ! empty( $origin ) ) {
		return strtoupper( $origin );
	}
	if ( function_exists( 'WC' ) ) {
		$wc_country = get_option( 'woocommerce_default_country', 'PL' );
		$parts      = explode( ':', $wc_country );
		return strtoupper( $parts[0] );
	}
	return 'PL';
}
}

/**
 * Sanitize ps_shipping_config option (admin panel shipping price card).
 */
if ( ! function_exists( 'parcselect_sanitize_shipping_config' ) ) {
function parcselect_sanitize_shipping_config( $input ) {
	if ( ! is_array( $input ) ) {
		return get_option( 'parcselect_shipping_config', array() );
	}
	return array(
		'free'          => ( isset( $input['free'] ) && 'yes' === $input['free'] ) ? 'yes' : 'no',
		'price'         => isset( $input['price'] ) ? wc_format_decimal( floatval( $input['price'] ), 2 ) : '',
		'delivery_time' => isset( $input['delivery_time'] ) ? sanitize_text_field( $input['delivery_time'] ) : '',
		'order_note'    => isset( $input['order_note'] ) ? sanitize_textarea_field( $input['order_note'] ) : '',
	);
}
}

/**
 * Sanitize ps_enabled_carriers option.
 * Free version supports InPost only; stored as JSON: ["inpost"]
 */
if ( ! function_exists( 'parcselect_sanitize_enabled_carriers' ) ) {
function parcselect_sanitize_enabled_carriers( $input ) {
	$allowed = array( 'inpost' );
	$checked = array_map( 'sanitize_key', (array) $input );
	$clean   = array();
	foreach ( $checked as $c ) {
		if ( in_array( $c, $allowed, true ) ) {
			$clean[] = $c;
		}
	}
	if ( empty( $clean ) ) {
		$clean = array( 'inpost' );
	}
	return wp_json_encode( $clean );
}
}

/**
 * Sync ps_default_country when ps_shop_origin_country changes (and vice versa).
 * Both keys serve similar purposes — keep them in sync.
 */
add_action( 'update_option_ps_shop_origin_country', function( $old, $new ) {
	$current_default = get_option( 'parcselect_default_country', '' );
	if ( strtolower( $new ) !== $current_default ) {
		update_option( 'parcselect_default_country', strtolower( $new ) );
	}
}, 20, 2 );

add_action( 'update_option_ps_default_country', function( $old, $new ) {
	$current_origin = get_option( 'parcselect_shop_origin_country', '' );
	if ( strtoupper( $new ) !== $current_origin ) {
		update_option( 'parcselect_shop_origin_country', strtoupper( $new ) );
	}
}, 20, 2 );

/**
 * Sanitize ps_custom_pickup option.
 */
if ( ! function_exists( 'parcselect_sanitize_custom_pickup' ) ) {
function parcselect_sanitize_custom_pickup( $input ) {
	if ( ! is_array( $input ) ) return get_option( 'parcselect_custom_pickup', array() );
	$allowed_zoom = array( 'direct', 'from_poland', 'from_europe', 'from_world' );
	return array(
		'enabled'    => ! empty( $input['enabled'] ) && $input['enabled'] === 'yes' ? 'yes' : 'no',
		'name'       => sanitize_text_field( $input['name'] ?? '' ),
		'street'     => sanitize_text_field( $input['street'] ?? '' ),
		'city'       => sanitize_text_field( $input['city'] ?? '' ),
		'postal'     => sanitize_text_field( $input['postal'] ?? '' ),
		'country'    => strtoupper( sanitize_text_field( $input['country'] ?? 'PL' ) ),
		'lat'        => (string) round( (float) ( $input['lat'] ?? 0 ), 7 ),
		'lng'        => (string) round( (float) ( $input['lng'] ?? 0 ), 7 ),
		'desc'       => sanitize_textarea_field( $input['desc'] ?? '' ),
		'hours'      => sanitize_text_field( $input['hours'] ?? '' ),
		'label'      => sanitize_text_field( $input['label'] ?? 'Pick up your order locally' ),
		'zoom_mode'  => in_array( $input['zoom_mode'] ?? '', $allowed_zoom, true ) ? $input['zoom_mode'] : 'from_europe',
		'show_first' => ! empty( $input['show_first'] ) ? 'yes' : 'no',
		'highlight'  => ! empty( $input['highlight'] ) ? 'yes' : 'no',
	);
}
}

/**
 * Sanitize parcselect_paczkomaty_options array before saving.
 * Merges with existing saved values so that submitting one tab doesn't wipe fields from another tab.
 */
if ( ! function_exists( 'parcselect_sanitize_behavior_options' ) ) {
function parcselect_sanitize_behavior_options( $input ) {
	// Start from existing saved value — so Maps tab save doesn't wipe Behavior tab fields and vice versa
	$existing = get_option( 'parcselect_paczkomaty_options', array() );
	$clean = is_array( $existing ) ? $existing : array();

	// Behavior tab fields — only update if this is a Behavior tab submit (key hint: ip_selected_as_shipping is always present from that form)
	// Maps tab doesn't send these keys → skip overwriting them if they're absent
	$is_behavior_submit = array_key_exists( 'ip_selected_as_shipping', $input )
	                   || array_key_exists( 'ip_select_weight_limit', $input )
	                   || array_key_exists( 'ip_select_weight_limit_value', $input )
	                   || array_key_exists( 'save_shortcode_cart_checkout', $input );

	if ( $is_behavior_submit ) {
		// Checkboxes: if not in POST they weren't submitted — treat as unchecked = 'no'
		$clean['ip_selected_as_shipping']          = isset( $input['ip_selected_as_shipping'] )          ? 'yes' : 'no';
		$clean['ip_select_weight_limit']           = isset( $input['ip_select_weight_limit'] )           ? 'yes' : 'no';
		$clean['ip_select_weight_limit_value']     = isset( $input['ip_select_weight_limit_value'] )
			? (string) floatval( $input['ip_select_weight_limit_value'] ) : '25';
		$clean['ip_select_weight_limit_result']    = in_array( $input['ip_select_weight_limit_result'] ?? '', array( 'hide', 'disable' ), true )
			? $input['ip_select_weight_limit_result'] : 'hide';
		$clean['ip_select_dimensions_limit']       = isset( $input['ip_select_dimensions_limit'] )       ? 'yes' : 'no';
		$clean['ip_select_dimensions_limit_width'] = isset( $input['ip_select_dimensions_limit_width'] )
			? (string) floatval( $input['ip_select_dimensions_limit_width'] ) : '';
		$clean['ip_select_dimensions_limit_height']= isset( $input['ip_select_dimensions_limit_height'] )
			? (string) floatval( $input['ip_select_dimensions_limit_height'] ) : '';
		$clean['ip_select_dimensions_limit_length']= isset( $input['ip_select_dimensions_limit_length'] )
			? (string) floatval( $input['ip_select_dimensions_limit_length'] ) : '';
		$clean['save_shortcode_cart_checkout']     = isset( $input['save_shortcode_cart_checkout'] )     ? 'yes' : 'no';
		$clean['parcselect_epic_zoom']                     = isset( $input['parcselect_epic_zoom'] )                     ? 'yes' : 'no';

		// New: Auto-search and Pre-popup timing
		$clean['parcselect_search_timeout'] = isset( $input['parcselect_search_timeout'] )
			? min( 5.0, max( 0.5, floatval( $input['parcselect_search_timeout'] ) ) ) : 1.2;
		$clean['parcselect_popup_delay']    = isset( $input['parcselect_popup_delay'] )
			? min( 60, max( 5, intval( $input['parcselect_popup_delay'] ) ) ) : 16;

		// SCAN TO DELIVER
		$clean['parcselect_std_enabled']     = isset( $input['parcselect_std_enabled'] ) ? 'yes' : 'no';
		$clean['parcselect_std_custom_logo'] = isset( $input['parcselect_std_custom_logo'] )
			? esc_url_raw( $input['parcselect_std_custom_logo'] ) : '';
	}

	// Appearance tab fields
	$is_appearance_submit = array_key_exists( 'ip_select_show_logo', $input )
	                     || array_key_exists( 'ip_select_show_logo_img', $input )
	                     || array_key_exists( 'ips_order_summary_layout', $input );

	if ( $is_appearance_submit ) {
		$clean['ip_select_show_logo']      = isset( $input['ip_select_show_logo'] ) && $input['ip_select_show_logo'] === 'no' ? 'no' : 'yes';
		$clean['ip_select_show_logo_img']  = isset( $input['ip_select_show_logo_img'] )
			? esc_url_raw( $input['ip_select_show_logo_img'] ) : '';
		$clean['ips_order_summary_layout'] = in_array( $input['ips_order_summary_layout'] ?? '', array( 'compact', 'full', 'minimal' ), true )
			? $input['ips_order_summary_layout'] : 'compact';
	}

	// Maps tab: enabled countries array (free version: InPost / Poland only)
	$allowed_countries = array( 'pl' );
	if ( isset( $input['ip_enabled_countries'] ) && is_array( $input['ip_enabled_countries'] ) ) {
		$clean['ip_enabled_countries'] = array_values(
			array_filter( $input['ip_enabled_countries'], function( $c ) use ( $allowed_countries ) {
				return in_array( sanitize_key( $c ), $allowed_countries, true );
			} )
		);
	}

	return $clean;
}
}

if ( ! function_exists( 'parcselect_render_settings_page' ) ) {
function parcselect_render_settings_page() {
	if ( ! current_user_can( 'manage_woocommerce' ) ) {
		return;
	}

	$engine  = parcselect_map_engine();
	$country = get_option( 'parcselect_default_country', 'pl' );

	$countries = array(
		'pl' => 'Polska (Poland)',
	);

	?>
	<div class="wrap">
		<h1 style="display:flex;align-items:center;gap:10px">
			<span>&#128230; Parcel Selector</span>
			<span style="font-size:13px;font-weight:400;color:#6b7280">v<?php echo esc_html( PARCSELECT_VERSION ); ?></span>
		</h1>

		<!-- WP.org FREE — no license section -->
		<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:12px;padding:24px 28px;margin-bottom:28px;max-width:800px">
			<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
				<strong style="font-size:16px;color:#111827"><?php esc_html_e( 'Free Version', 'parcel-selector' ); ?></strong>
				<span style="background:#16a34a;color:#fff;font-size:11px;font-weight:700;padding:3px 10px;border-radius:100px;letter-spacing:.5px">
					<?php esc_html_e( 'Active', 'parcel-selector' ); ?>
				</span>
				<span style="background:#f97316;color:#fff;font-size:11px;font-weight:700;padding:3px 10px;border-radius:100px">FREE</span>
			</div>
			<p style="margin:0;font-size:14px;color:#374151"><?php esc_html_e( 'No license required. Unlimited orders. InPost Paczkomaty in Poland.', 'parcel-selector' ); ?></p>
		</div>
		<form method="post" action="options.php">
			<?php settings_fields( 'parcselect_settings_group' ); ?>
			<table class="form-table" role="presentation">
				<tr>
					<th scope="row">
						<label for="ps_map_engine"><?php esc_html_e( 'Map Engine', 'parcel-selector' ); ?></label>
					</th>
					<td>
						<select name="ps_map_engine" id="ps_map_engine">
							<option value="anunaki" <?php selected( $engine, 'anunaki' ); ?>>
								<?php esc_html_e( 'ANUNAKI — Live data from parcelselector.com API (recommended)', 'parcel-selector' ); ?>
							</option>
							<option value="classic" <?php selected( $engine, 'classic' ); ?>>
								<?php esc_html_e( 'Classic — Lightweight map UI (server-loaded points)', 'parcel-selector' ); ?>
							</option>
						</select>
						<p class="description">
							<?php esc_html_e( 'ANUNAKI: Full-featured map. Pickup points loaded from the ParcelSelector.com server. Recommended.', 'parcel-selector' ); ?><br>
							<?php esc_html_e( 'Classic: Lightweight map UI. Pickup points loaded from the ParcelSelector.com server (same data source as ANUNAKI).', 'parcel-selector' ); ?>
						</p>
					</td>
				</tr>
				<tr>
					<th scope="row">
						<label for="ps_default_country"><?php esc_html_e( 'Default Country', 'parcel-selector' ); ?></label>
					</th>
					<td>
						<select name="ps_default_country" id="ps_default_country">
							<?php foreach ( $countries as $code => $name ) : ?>
								<option value="<?php echo esc_attr( $code ); ?>" <?php selected( $country, $code ); ?>>
									<?php echo esc_html( $name ); ?>
								</option>
							<?php endforeach; ?>
						</select>
						<p class="description"><?php esc_html_e( 'The map will open on this country by default.', 'parcel-selector' ); ?></p>
					</td>
				</tr>
			</table>
			<?php submit_button(); ?>
		</form>
		<hr>
		<h2><?php esc_html_e( 'Supported Country', 'parcel-selector' ); ?></h2>
		<p><?php esc_html_e( 'Poland (InPost Paczkomaty).', 'parcel-selector' ); ?></p>
		<h2><?php esc_html_e( 'Setup Guide', 'parcel-selector' ); ?></h2>
		<ol>
			<li><?php esc_html_e( 'Go to WooCommerce > Settings > Shipping > Shipping Zones', 'parcel-selector' ); ?></li>
			<li><?php esc_html_e( 'Add or edit a shipping zone', 'parcel-selector' ); ?></li>
			<li><?php esc_html_e( 'Click "Add shipping method" and select "InPost Parcel Locker"', 'parcel-selector' ); ?></li>
			<li><?php esc_html_e( 'The map button will appear automatically on the checkout page', 'parcel-selector' ); ?></li>
		</ol>
	</div>
	<?php
}
}

// ============================================================================
// Plugin action links
// ============================================================================

add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'parcselect_plugin_action_links' );

if ( ! function_exists( 'parcselect_plugin_action_links' ) ) {
function parcselect_plugin_action_links( $links ) {
	$settings_url = admin_url( 'admin.php?page=parcelselector-settings' );
	$settings = '<a href="' . esc_url( $settings_url ) . '">' . esc_html__( 'Settings', 'parcel-selector' ) . '</a>';
	array_unshift( $links, $settings );


	return $links;
}
}



// ============================================================================
// AJAX: Export / Import settings
// ============================================================================

add_action( 'wp_ajax_parcselect_export_settings', 'parcselect_ajax_export_settings' );
add_action( 'wp_ajax_parcselect_import_settings', 'parcselect_ajax_import_settings' );
if ( ! function_exists( 'parcselect_ajax_export_settings' ) ) {
function parcselect_ajax_export_settings() {
	check_ajax_referer( 'parcselect_export_settings', 'nonce' );
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( array( 'message' => 'Access denied.' ), 403 );
	}

	$settings = array(
		'_export_version'  => PARCSELECT_VERSION,
		'_export_site'     => home_url( '/' ),
		// Core plugin settings
		'parcselect_map_engine'    => get_option( 'parcselect_map_engine', 'classic' ),
		'parcselect_default_country' => get_option( 'parcselect_default_country', 'pl' ),
		// Behavior options (parcselect_paczkomaty_options)
		'parcselect_paczkomaty_options' => get_option( 'parcselect_paczkomaty_options', array() ),
	);

	wp_send_json_success( array(
		'settings' => $settings,
		'date'     => wp_date( 'Y-m-d' ),
	) );
}
}

if ( ! function_exists( 'parcselect_ajax_import_settings' ) ) {
function parcselect_ajax_import_settings() {
	check_ajax_referer( 'parcselect_import_settings', 'nonce' );
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( array( 'message' => 'Access denied.' ), 403 );
	}

	$raw  = isset( $_POST['settings'] ) ? sanitize_text_field( wp_unslash( $_POST['settings'] ) ) : '';
	$data = json_decode( $raw, true );

	if ( ! is_array( $data ) ) {
		wp_send_json_error( array( 'message' => 'Invalid settings data.' ) );
	}

	// Recursively sanitize all string values inside the decoded JSON.
	$data = map_deep( $data, 'sanitize_text_field' );

	$imported = 0;

	// Map engine
	if ( isset( $data['parcselect_map_engine'] ) && in_array( $data['parcselect_map_engine'], array( 'anunaki', 'classic' ), true ) ) {
		update_option( 'parcselect_map_engine', $data['parcselect_map_engine'] );
		$imported++;
	}
	// Default country
	if ( isset( $data['parcselect_default_country'] ) ) {
		update_option( 'parcselect_default_country', sanitize_key( $data['parcselect_default_country'] ) );
		$imported++;
	}
	// Behavior options — run through sanitizer
	if ( isset( $data['parcselect_paczkomaty_options'] ) && is_array( $data['parcselect_paczkomaty_options'] ) ) {
		$clean = parcselect_sanitize_behavior_options( $data['parcselect_paczkomaty_options'] );
		update_option( 'parcselect_paczkomaty_options', $clean );
		$imported++;
	}

	if ( $imported === 0 ) {
		wp_send_json_error( array( 'message' => 'No recognized settings found in this file.' ) );
	}

	wp_send_json_success( array(
		'message' => 'Settings imported successfully (' . $imported . ' groups). Page will reload.',
	) );
}
}

// ============================================================================
// AJAX: Unified Save for PANEL_ADMINA
// ============================================================================

add_action( 'wp_ajax_parcselect_save_panel_settings', 'parcselect_ajax_save_panel_settings' );

/**
 * AJAX: Setup Wizard complete — saves origin, carriers, engine, triggers auto-enable.
 */
add_action( 'wp_ajax_parcselect_wizard_complete', 'parcselect_ajax_wizard_complete' );
if ( ! function_exists( 'parcselect_ajax_wizard_complete' ) ) {
function parcselect_ajax_wizard_complete() {
	check_ajax_referer( 'parcselect_wizard_save', 'nonce' );
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( array( 'message' => 'Access denied.' ), 403 );
	}

	$origin   = strtoupper( sanitize_key( isset( $_POST['origin'] ) ? $_POST['origin'] : 'PL' ) );
	$engine   = sanitize_key( isset( $_POST['engine'] ) ? $_POST['engine'] : 'anunaki' );
	$carriers = isset( $_POST['carriers'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['carriers'] ) ), true ) : array();

	if ( ! preg_match( '/^[A-Z]{2}$/', $origin ) ) { $origin = 'PL'; }
	if ( ! in_array( $engine, array( 'anunaki', 'classic' ), true ) ) { $engine = 'anunaki'; }
	if ( ! is_array( $carriers ) || empty( $carriers ) ) { $carriers = array( 'inpost' ); }

	$valid = array( 'inpost' );
	$carriers = array_values( array_intersect( $carriers, $valid ) );
	if ( empty( $carriers ) ) { $carriers = array( 'inpost' ); }

	// Save all settings.
	update_option( 'parcselect_shop_origin_country', $origin );
	update_option( 'parcselect_map_engine', $engine );
	update_option( 'parcselect_enabled_carriers', wp_json_encode( $carriers ) );

	// Mark wizard as completed.
	update_option( 'parcselect_wizard_completed', 'yes' );

	wp_send_json_success( array( 'message' => 'Setup complete.' ) );
}
}

/**
 * Redirect to Setup Wizard on first activation (once only).
 */
if ( ! function_exists( 'parcselect_maybe_redirect_to_wizard' ) ) {
function parcselect_maybe_redirect_to_wizard() {
	if ( ! is_admin() || wp_doing_ajax() || wp_doing_cron() ) { return; }
	// Never redirect during bulk plugin activation (WP.org Guideline 11).
	if ( isset( $_GET['activate-multi'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only presence check, no data used.
	$transient = get_transient( 'parcselect_activation_redirect' );
	if ( ! $transient ) { return; }
	delete_transient( 'parcselect_activation_redirect' );

	// Free version: redirect to main settings page instead of wizard (wizard removed).
	wp_safe_redirect( admin_url( 'admin.php?page=parcelselector-settings' ) );
	exit;
}
}
add_action( 'admin_init', 'parcselect_maybe_redirect_to_wizard', 1 );
if ( ! function_exists( 'parcselect_ajax_save_panel_settings' ) ) {
function parcselect_ajax_save_panel_settings() {
	check_ajax_referer( 'parcselect_panel_save', 'nonce' );
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_send_json_error( array( 'message' => 'Access denied.' ), 403 );
	}

	$group = isset( $_POST['group'] ) ? sanitize_key( $_POST['group'] ) : '';
	$data  = isset( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per-field inside switch() below via Settings API callbacks.

	if ( ! $group || ! is_array( $data ) ) {
		wp_send_json_error( array( 'message' => 'Invalid data provided.' ) );
	}

	// We utilize existing sanitization logic via Settings API or manual calls
	switch ( $group ) {
		case 'parcselect_settings_group':
			if ( isset( $data['parcselect_map_engine'] ) ) {
				update_option( 'parcselect_map_engine', in_array( $data['parcselect_map_engine'], array( 'anunaki', 'classic' ), true ) ? $data['parcselect_map_engine'] : 'anunaki' );
			}
			if ( isset( $data['parcselect_default_country'] ) ) {
				update_option( 'parcselect_default_country', sanitize_key( $data['parcselect_default_country'] ) );
			}
			break;

		case 'parcselect_behavior_group':
			// This group handles parcselect_paczkomaty_options, ps_custom_pickup, and ps_shipping_config
			if ( isset( $data['parcselect_paczkomaty_options'] ) ) {
				$clean = parcselect_sanitize_behavior_options( $data['parcselect_paczkomaty_options'] );
				update_option( 'parcselect_paczkomaty_options', $clean );
			}
			if ( isset( $data['parcselect_custom_pickup'] ) ) {
				$clean = parcselect_sanitize_custom_pickup( $data['parcselect_custom_pickup'] );
				update_option( 'parcselect_custom_pickup', $clean );
			}
			if ( isset( $data['parcselect_shipping_config'] ) ) {
				$clean = parcselect_sanitize_shipping_config( $data['parcselect_shipping_config'] );
				update_option( 'parcselect_shipping_config', $clean );
			}
			// Reload page so country grid reflects saved state.
			wp_send_json_success( array( 'message' => 'Settings saved successfully.', 'reload' => true ) );
			break; // defense-in-depth: wp_send_json_success calls die(), but break guards against refactors.

		case 'parcselect_origin_group':
			if ( isset( $data['parcselect_shop_origin_country'] ) ) {
				$val = strtoupper( sanitize_key( $data['parcselect_shop_origin_country'] ) );
				if ( preg_match( '/^[A-Z]{2}$/', $val ) ) {
					update_option( 'parcselect_shop_origin_country', $val );
				}
			}
			// Tell JS to reload the page — the route table needs server-side re-render.
			wp_send_json_success( array( 'message' => 'Settings saved successfully.', 'reload' => true ) );
			break; // defense-in-depth

		case 'parcselect_appearance_group':
			if ( isset( $data['parcselect_admin_theme'] ) ) {
				update_user_meta( get_current_user_id(), 'parcselect_admin_theme', sanitize_key( $data['parcselect_admin_theme'] ) );
			}
			if ( isset( $data['parcselect_paczkomaty_options'] ) ) {
				$clean = parcselect_sanitize_behavior_options( $data['parcselect_paczkomaty_options'] );
				update_option( 'parcselect_paczkomaty_options', $clean );
			}
			break;

		case 'parcselect_carriers_group':
			if ( isset( $data['parcselect_enabled_carriers'] ) ) {
				$clean = parcselect_sanitize_enabled_carriers( $data['parcselect_enabled_carriers'] );
				update_option( 'parcselect_enabled_carriers', $clean );
			}
			if ( isset( $data['parcselect_show_legend'] ) ) {
				update_option( 'parcselect_show_legend', 'yes' === $data['parcselect_show_legend'] ? 'yes' : 'no' );
			}
			if ( isset( $data['parcselect_show_point_types'] ) ) {
				update_option( 'parcselect_show_point_types', 'yes' === $data['parcselect_show_point_types'] ? 'yes' : 'no' );
			}
			break;


		default:
			wp_send_json_error( array( 'message' => 'Unknown settings group.' ) );
	}

	wp_send_json_success( array( 'message' => 'Settings saved successfully.' ) );
}
}

// ============================================================================
// Activation hook
// ============================================================================

register_activation_hook( __FILE__, 'parcselect_activate' );

if ( ! function_exists( 'parcselect_activate' ) ) {
function parcselect_activate() {
	// LEGACY PREFIX MIGRATION ──────────────────────────────────────────────
	// Rename legacy options (parcsepa_*, ps_label_*, inpost_*), user meta and
	// WooCommerce shipping-method instance settings to the parcselect_ prefix.
	// Must run BEFORE the cross-install guard below — that guard reads
	// parcselect_label_sweep_installation_id, which only exists post-migration.
	// Idempotent — short-circuits when parcselect_migration_v1_done is set.
	if ( class_exists( 'Parcselect_Migration' ) ) {
		Parcselect_Migration::maybe_run();
	}

	// CROSS-INSTALL LEAK GUARD ─────────────────────────────────────────────
	// On every activation: if the parcselect_installation_id stored on disk does
	// not match the one recorded by the last sweep (or no sweep ran yet),
	// purge ALL _ps_label_* postmeta from every order. This protects users
	// who install the plugin onto a DB that already carries label meta from
	// a different shop / previous install / site export. Without this, the
	// metabox would surface "ETYKIETA opłacona" + a download link that
	// proxies a PDF from another shop's VPS session.
	$installation_guard = __DIR__ . '/PS_LABEL/class-ps-label-installation.php';
	if ( file_exists( $installation_guard ) ) {
		require_once $installation_guard;
		if ( class_exists( 'Parcselect_Label_Installation' ) ) {
			Parcselect_Label_Installation::bulk_sweep_if_installation_changed();
		}
	}

	// v4.3.8 TIER 1 AUTOCFG — run at activation:
	//   - generate UUID v4 installation_id if missing (NS-49 webhook OUT prerequisite)
	//   - probe Oracle pool, pick fastest reachable URL, cache 5 min
	//   - flag WC auto-install if WooCommerce not active (deferred to admin_init)
	$autocfg_path = __DIR__ . '/includes/class-ps-label-autoconfig.php';
	if ( file_exists( $autocfg_path ) ) {
		require_once $autocfg_path;
		if ( class_exists( 'Parcselect_Label_Autoconfig' ) ) {
			Parcselect_Label_Autoconfig::on_activation();
		}
	}

	// v4.4.5 M3 — utwórz dedykowaną tabelę DB na bajty PDF etykiet.
	// PDF nie są już zapisywane w publicznie serwowanym uploads/etykietownik.
	$pdf_store_path = __DIR__ . '/PS_LABEL/class-ps-label-pdf-store.php';
	if ( file_exists( $pdf_store_path ) ) {
		require_once $pdf_store_path;
		if ( class_exists( 'Parcselect_Label_Pdf_Store' ) ) {
			Parcselect_Label_Pdf_Store::install_table();
		}
	}

	// Trigger Setup Wizard redirect on first activation.
	if ( 'yes' !== get_option( 'parcselect_wizard_completed', '' ) ) {
		set_transient( 'parcselect_activation_redirect', 'yes', 60 );
	}

	// Set defaults on first activation
	if ( false === get_option( 'parcselect_map_engine' ) ) {
		add_option( 'parcselect_map_engine', 'anunaki' );
	}
	if ( false === get_option( 'parcselect_default_country' ) ) {
		add_option( 'parcselect_default_country', 'pl' );
	}
	// Default label service URL (public). Admin can override via the
	// parcselect_label_oracle_url option.
	if ( false === get_option( 'parcselect_label_oracle_url' ) ) {
		add_option( 'parcselect_label_oracle_url', 'https://api.parcelselector.com' );
	}
	// v4.3.7 (NS-23 fix 2026-04-30) — Implied consent dla infrastruktury map.parcelselector.com.
	// map. subdomena jest na infrastructure whitelist (NIE jest phone-home/telemetria).
	// Bez tego defaultu fresh install dostaje pusta mape — punkty zablokowane przez consent gate.
	// Telemetria/diagnostics nadal wymaga osobnego opt-in poprzez admin notice.
	if ( false === get_option( 'parcselect_consent_given' ) ) {
		add_option( 'parcselect_consent_given', 1 );
	}
	// Free version: InPost Paczkomaty only. The map shows InPost lockers exclusively.
	if ( false === get_option( 'parcselect_enabled_carriers' ) ) {
		add_option( 'parcselect_enabled_carriers', wp_json_encode( array( 'inpost' ) ) );
	}
	// Default enabled country.
	if ( false === get_option( 'parcselect_paczkomaty_options' ) ) {
		add_option( 'parcselect_paczkomaty_options', array(
			'ip_enabled_countries' => array( 'pl' ),
		) );
	}

	// Automatically register shipping method for all supported countries in WooCommerce.
	// Only run if WC is ready. Wrap in try-catch to prevent fatal during activation.
	if ( class_exists( 'WooCommerce' ) && class_exists( 'WC_Shipping_Zones' ) && class_exists( 'WC_Shipping_Zone' ) ) {
		try {
			// Free version: InPost Paczkomaty, Poland only — must match is_available() in class-ps-shipping-method.php
			$ps_countries = array( 'PL' );

			// ── 1. Create (or reuse) dedicated "InPost Parcel Lockers" shipping zone ──
			// This zone covers all 23 supported countries explicitly, so InPost always
			// appears regardless of what other zones the merchant has configured.
			$ps_zone_id = get_option( 'parcselect_shipping_zone_id', 0 );
			$ps_zone    = $ps_zone_id ? new WC_Shipping_Zone( $ps_zone_id ) : null;

			// Validate the stored zone still exists
			if ( ! $ps_zone || ! $ps_zone->get_id() ) {
				$ps_zone = new WC_Shipping_Zone();
				$ps_zone->set_zone_name( 'InPost Parcel Lockers' );
				$ps_zone->set_zone_order( 0 ); // highest priority — checked before other zones
				$ps_zone->save();
				update_option( 'parcselect_shipping_zone_id', $ps_zone->get_id() );
			}

			// Set zone locations = all 22 supported countries
			$locations = array();
			foreach ( $ps_countries as $cc ) {
				$locations[] = array( 'code' => $cc, 'type' => 'country' );
			}
			$ps_zone->set_locations( $locations );
			$ps_zone->save();

			// Add InPost method to this zone if not already present
			$zone_methods = $ps_zone->get_shipping_methods();
			$zone_has_inpost = false;
			foreach ( $zone_methods as $zm ) {
				if ( $zm->id === 'parcselect_inpost_paczkomat' ) {
					$zone_has_inpost = true;
					break;
				}
			}
			if ( ! $zone_has_inpost ) {
				$iid = $ps_zone->add_shipping_method( 'parcselect_inpost_paczkomat' );
				if ( $iid ) {
					$m = WC_Shipping_Zones::get_shipping_method( $iid );
					if ( $m ) { $m->update_option( 'enabled', 'yes' ); }
				}
			}

			// ── 2. Also add to "Rest of the World" (Zone 0) as universal fallback ──
			$zone_0   = new WC_Shipping_Zone( 0 );
			$exists_0 = false;
			foreach ( $zone_0->get_shipping_methods() as $method ) {
				if ( $method->id === 'parcselect_inpost_paczkomat' ) { $exists_0 = true; break; }
			}
			if ( ! $exists_0 ) {
				$iid = $zone_0->add_shipping_method( 'parcselect_inpost_paczkomat' );
				if ( $iid ) {
					$m = WC_Shipping_Zones::get_shipping_method( $iid );
					if ( $m ) { $m->update_option( 'enabled', 'yes' ); }
				}
			}

			// ── 3. Add to all other existing merchant-defined zones ──
			// Ensures InPost shows up even inside custom zones the merchant already configured.
			foreach ( WC_Shipping_Zones::get_zones() as $zone_data ) {
				if ( (int) $zone_data['id'] === (int) $ps_zone->get_id() ) {
					continue; // skip our own zone — already handled above
				}
				$zone   = new WC_Shipping_Zone( $zone_data['id'] );
				$exists = false;
				foreach ( $zone->get_shipping_methods() as $method ) {
					if ( $method->id === 'parcselect_inpost_paczkomat' ) { $exists = true; break; }
				}
				if ( ! $exists ) {
					$iid = $zone->add_shipping_method( 'parcselect_inpost_paczkomat' );
					if ( $iid ) {
						$m = WC_Shipping_Zones::get_shipping_method( $iid );
						if ( $m ) { $m->update_option( 'enabled', 'yes' ); }
					}
				}
			}
		} catch ( Exception $e ) {
			// Silently fail — allow plugin to activate even if WC has issues
		} catch ( Error $e ) {
			// Catch PHP 7+ errors too
		}
	}


	// Signal admin_init to redirect to plugin settings with a welcome message.
	set_transient( 'parcselect_activation_redirect', 1, 60 );

	flush_rewrite_rules();
}
}

// ============================================================================
// Debug helper
// ============================================================================

/**
 * Conditional error_log — only when PARCSELECT_DEBUG is true.
 *
 * @param string $message Log message (ASCII only recommended).
 * @param string $level   'info' | 'warn' | 'error' | 'debug'
 */
if ( ! function_exists( 'parcselect_log' ) ) {
function parcselect_log( $message, $level = 'info' ) {
	// Always write to WC logger (creates parcel-selector log file in wc-logs/)
	// so the Testing tab log viewer works regardless of PARCSELECT_DEBUG.
	if ( function_exists( 'wc_get_logger' ) ) {
		$wc_level = WC_Log_Levels::INFO;
		if ( 'error' === $level ) $wc_level = WC_Log_Levels::ERROR;
		elseif ( 'warn' === $level ) $wc_level = WC_Log_Levels::WARNING;
		elseif ( 'debug' === $level ) $wc_level = WC_Log_Levels::DEBUG;
		wc_get_logger()->log( $wc_level, $message, array( 'source' => 'parcel-selector' ) );
	}

	if ( ! PARCSELECT_DEBUG ) {
		return;
	}
	$prefix = '[PS';
	switch ( $level ) {
		case 'error': $prefix .= ' ERR'; break;
		case 'warn':  $prefix .= ' WARN'; break;
		case 'debug': $prefix .= ' DBG'; break;
	}
	$prefix .= '] ';
	error_log( $prefix . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional debug logging, guarded by PARCSELECT_DEBUG constant.
}
}


/* ======================================================================
 * BUG #12 (2026-05-15) — receiver phone 9-digit validator (cart + checkout)
 * Furgonetka API rejects /receiver/phone unless exactly 9 digits (after +48).
 * Sender phone is validated in Stage 2 stepper; this adds the missing
 * billing_phone (receiver) validation on WC checkout, both server-side and
 * client-side (HTML5 pattern). Stage 4 fallback lives in
 * includes/class-ps-label-embed.php::ajax_create_session() with marker
 * 'invalid_receiver_phone'.
 * Marker (idempotency): parcselect_validate_billing_phone
 * ====================================================================== */
if ( ! function_exists( 'parcselect_validate_billing_phone' ) ) {
add_action( 'woocommerce_after_checkout_validation', 'parcselect_validate_billing_phone', 10, 2 );
function parcselect_validate_billing_phone( $data, $errors ) {
	if ( empty( $data['shipping_method'] ) ) {
		return;
	}
	$is_parcsepa = false;
	foreach ( (array) $data['shipping_method'] as $method ) {
		if ( false !== strpos( (string) $method, 'parcselect_inpost_paczkomat' ) ) {
			$is_parcsepa = true;
			break;
		}
	}
	if ( ! $is_parcsepa ) {
		return;
	}
	// PHONE_SOURCES_FIX 2026-05-16: shipping_phone (odbiorca) priorytet.
	$phone = '';
	if ( ! empty( $data['shipping_phone'] ) ) { $phone = (string) $data['shipping_phone']; }
	elseif ( ! empty( $data['billing_phone'] ) ) { $phone = (string) $data['billing_phone']; }
	if ( '' === trim( $phone ) ) { return; }
	$digits = preg_replace( '/\D/', '', $phone );
	if ( strlen( $digits ) === 11 && substr( $digits, 0, 2 ) === '48' ) {
		$digits = substr( $digits, 2 );
	}
	if ( strlen( $digits ) !== 9 ) {
		$errors->add(
			'parcselect_phone_invalid',
			__( 'The phone number must be exactly 9 digits. InPost requires a 9-digit mobile phone number in Poland — the country prefix +48 is added automatically before the number.', 'parcel-selector' )
		);
	}
}
}

/* ======================================================================
 * BUG #12b (2026-05-16) — receiver phone validator dla BLOCK CHECKOUT.
 * woocommerce_after_checkout_validation NIE odpala sie dla WooCommerce
 * Blocks / Store API checkout (WC 8.3+). Bez tego 8-cyfrowy telefon
 * przechodzil checkout (order #215 KapsulaSelect) i Furgonetka odrzucala
 * etykiete (/receiver/phone invalidFixedLimit). Ten walidator dziala
 * przez Store API hook i rzuca RouteException -> blok checkout pokazuje blad.
 * Marker (idempotency): parcselect_validate_billing_phone_blocks
 * ====================================================================== */
if ( ! function_exists( 'parcselect_validate_billing_phone_blocks' ) ) {
add_action( 'woocommerce_store_api_checkout_update_order_from_request', 'parcselect_validate_billing_phone_blocks', 10, 2 );
function parcselect_validate_billing_phone_blocks( $order, $request ) {
	if ( ! $order instanceof WC_Order ) {
		return;
	}
	// Czy wybrano paczkomat parcselect (shipping method LUB locker w sesji)?
	$is_parcsepa = false;
	foreach ( (array) $order->get_shipping_methods() as $method ) {
		if ( is_object( $method ) && false !== strpos( (string) $method->get_method_id(), 'parcselect_inpost_paczkomat' ) ) {
			$is_parcsepa = true;
			break;
		}
	}
	if ( ! $is_parcsepa && function_exists( 'WC' ) && WC()->session ) {
		$_locker = WC()->session->get( 'parcselect_locker_name', '' );
		if ( ! empty( $_locker ) ) { $is_parcsepa = true; }
	}
	if ( ! $is_parcsepa ) {
		return;
	}
	// PHONE_SOURCES_FIX 2026-05-16: telefon ODBIORCY = adres DOSTAWY. Block
	// checkout trzyma numer w shipping_phone (pole Telefon w sekcji Adres do
	// wysylki), NIE w billing_phone. 4 zrodla, priorytet shipping -> billing,
	// $order + surowy $request. Brak numeru -> NIE blokujemy (bez false-positive).
	$phone = '';
	$_cand = array();
	if ( method_exists( $order, 'get_shipping_phone' ) ) {
		$_cand[] = (string) $order->get_shipping_phone();
	}
	if ( ( is_object( $request ) || is_array( $request ) ) && isset( $request['shipping_address'] ) ) {
		$_sa = $request['shipping_address'];
		if ( is_object( $_sa ) ) { $_sa = (array) $_sa; }
		if ( is_array( $_sa ) && isset( $_sa['phone'] ) ) { $_cand[] = (string) $_sa['phone']; }
	}
	$_cand[] = (string) $order->get_billing_phone();
	if ( ( is_object( $request ) || is_array( $request ) ) && isset( $request['billing_address'] ) ) {
		$_ba = $request['billing_address'];
		if ( is_object( $_ba ) ) { $_ba = (array) $_ba; }
		if ( is_array( $_ba ) && isset( $_ba['phone'] ) ) { $_cand[] = (string) $_ba['phone']; }
	}
	foreach ( $_cand as $_c ) {
		if ( '' !== trim( (string) $_c ) ) { $phone = (string) $_c; break; }
	}
	if ( '' === trim( $phone ) ) {
		return;
	}
	$digits = preg_replace( '/[^0-9]/', '', $phone );
	if ( strlen( $digits ) === 11 && substr( $digits, 0, 2 ) === '48' ) {
		$digits = substr( $digits, 2 );
	}
	if ( strlen( $digits ) === 12 && substr( $digits, 0, 3 ) === '048' ) {
		$digits = substr( $digits, 3 );
	}
	if ( strlen( $digits ) !== 9 ) {
		throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
			'parcselect_phone_invalid',
			esc_html__( 'The phone number must be exactly 9 digits. InPost requires a 9-digit mobile phone number in Poland — the country prefix +48 is added automatically before the number.', 'parcel-selector' ),
			400
		);
	}
}
}

if ( ! function_exists( 'parcselect_phone_pattern_filter' ) ) {
add_filter( 'woocommerce_checkout_fields', 'parcselect_phone_pattern_filter', 999 );
function parcselect_phone_pattern_filter( $fields ) {
	if ( isset( $fields['billing']['billing_phone'] ) ) {
		$fields['billing']['billing_phone']['required'] = true;
		if ( ! isset( $fields['billing']['billing_phone']['custom_attributes'] ) || ! is_array( $fields['billing']['billing_phone']['custom_attributes'] ) ) {
			$fields['billing']['billing_phone']['custom_attributes'] = array();
		}
		$fields['billing']['billing_phone']['custom_attributes']['pattern']   = '(\+?48)?[\s\-]?\d{3}[\s\-]?\d{3}[\s\-]?\d{3}';
		$fields['billing']['billing_phone']['custom_attributes']['inputmode'] = 'numeric';
		$fields['billing']['billing_phone']['custom_attributes']['minlength'] = 9;
		$fields['billing']['billing_phone']['custom_attributes']['title']     = __( '9 digits after the +48 prefix (required for parcel locker)', 'parcel-selector' );
		$fields['billing']['billing_phone']['description']                    = __( 'For an InPost parcel locker: exactly 9 mobile phone digits.', 'parcel-selector' );
	}
	return $fields;
}
}
