<?php
/**
 * Location Service for city/postal validation and session management.
 *
 * @package FoodOrd
 */

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

/**
 * Class Foodord_Location_Service
 *
 * Provides helpers to access Delivery Settings (location based),
 * validate a user-provided city/postal pair, and persist the selection
 * in WooCommerce session.
 */
class Foodord_Location_Service {
	/** Session key names. */
	const SESSION_ALLOWED_KEY       = 'foodord_location_allowed';
	const SESSION_CITY_KEY          = 'foodord_location_city';
	const SESSION_POSTAL_KEY        = 'foodord_location_postal';
	const SESSION_FEE_KEY           = 'foodord_location_fee';
	const SESSION_MINIMUM_KEY       = 'foodord_location_minimum';
	const SESSION_RULES_VERSION_KEY = 'foodord_location_rules_version';
	const SESSION_ADDRESS_KEY       = 'foodord_location_address';
	const SESSION_LAT_KEY           = 'foodord_location_lat';
	const SESSION_LNG_KEY           = 'foodord_location_lng';
	const SESSION_ZONE_RADIUS_KEY   = 'foodord_location_zone_radius';

	/**
	 * Determine Google Maps language parameter from WordPress locale.
	 *
	 * @param string $context Optional context for filters (frontend, admin, geocode, etc.).
	 * @return string
	 */
	public static function get_google_maps_language( string $context = 'frontend' ): string {
		if ( function_exists( 'determine_locale' ) ) {
			$locale = determine_locale();
		} else {
			$locale = get_locale();
		}

		$locale     = is_string( $locale ) && '' !== $locale ? $locale : 'en';
		$normalized = str_replace( '_', '-', $locale );
		$normalized = preg_replace( '/[^A-Za-z0-9\-]/', '', $normalized );
		if ( '' === $normalized ) {
			$normalized = 'en';
		}

		$parts = explode( '-', $normalized );
		if ( ! empty( $parts ) ) {
			$parts[0]    = strtolower( $parts[0] );
			$parts_count = count( $parts );
			for ( $i = 1; $i < $parts_count; $i++ ) {
				$parts[ $i ] = strtoupper( $parts[ $i ] );
			}
			$normalized = implode( '-', $parts );
		}

		/**
		 * Filter Google Maps language code used by FoodOrd.
		 *
		 * @param string $normalized Locale string formatted for Google Maps (e.g. "en", "nl-BE").
		 * @param string $context   Context identifier.
		 */
		return apply_filters( 'foodord_google_maps_language', $normalized, $context );
	}

	/**
	 * Get configured delivery mode.
	 *
	 * @return string
	 */
	public static function get_delivery_mode(): string {
		$mode = get_option( 'foodord_delivery_mode', 'postal' );
		return in_array( $mode, array( 'postal', 'radius' ), true ) ? $mode : 'postal';
	}

	/**
	 * Retrieve configured radius zones.
	 *
	 * @return array<int, array{radius: float, minimum: float, fee: float, label: string}>
	 */
	public static function get_radius_zones(): array {
		$zones = get_option( 'foodord_delivery_radius_zones', array() );
		$zones = self::sanitize_radius_zones( $zones );

		if ( empty( $zones ) ) {
			$legacy_radius = (float) get_option( 'foodord_delivery_radius_distance', 0 );
			$legacy_min    = (float) get_option( 'foodord_delivery_radius_minimum', 0 );
			$legacy_fee    = (float) get_option( 'foodord_delivery_radius_fee', 0 );

			if ( $legacy_radius > 0 ) {
				$zones = array(
					array(
						'radius'  => $legacy_radius,
						'minimum' => max( 0.0, $legacy_min ),
						'fee'     => max( 0.0, $legacy_fee ),
						'label'   => '',
					),
				);
			}
		}

		return $zones;
	}

	/**
	 * Retrieve radius delivery configuration.
	 *
	 * @return array{distance: float, minimum: float, fee: float, maps_api_key: string, center_lat: float|null, center_lng: float|null, store_address: string, zones: array<int, array{radius: float, minimum: float, fee: float, label: string}>}
	 */
	public static function get_radius_settings(): array {
		$zones           = self::get_radius_zones();
		$max_distance    = 0.0;
		$default_minimum = 0.0;
		$default_fee     = 0.0;

		if ( ! empty( $zones ) ) {
			$last_index      = count( $zones ) - 1;
			$max_distance    = isset( $zones[ $last_index ]['radius'] ) ? (float) $zones[ $last_index ]['radius'] : 0.0;
			$default_minimum = isset( $zones[0]['minimum'] ) ? (float) $zones[0]['minimum'] : 0.0;
			$default_fee     = isset( $zones[0]['fee'] ) ? (float) $zones[0]['fee'] : 0.0;
		}

		$maps_api_key = (string) get_option( 'foodord_maps_api_key', '' );
		$coords       = self::ensure_radius_center_coordinates( $maps_api_key );

		return array(
			'distance'      => $max_distance,
			'minimum'       => $default_minimum,
			'fee'           => $default_fee,
			'maps_api_key'  => $maps_api_key,
			'center_lat'    => $coords['lat'],
			'center_lng'    => $coords['lng'],
			'store_address' => self::get_store_address_string(),
			'zones'         => $zones,
		);
	}

	/**
	 * Sanitize radius zones option value.
	 *
	 * @param mixed $zones Raw zones value.
	 * @return array<int, array{radius: float, minimum: float, fee: float, label: string}>
	 */
	private static function sanitize_radius_zones( $zones ): array {
		if ( ! is_array( $zones ) ) {
			return array();
		}

		$sanitized = array();
		foreach ( $zones as $zone ) {
			if ( ! is_array( $zone ) ) {
				continue;
			}
			$radius = isset( $zone['radius'] ) ? (float) $zone['radius'] : ( isset( $zone['radius_km'] ) ? (float) $zone['radius_km'] : ( isset( $zone['distance'] ) ? (float) $zone['distance'] : 0.0 ) );
			if ( $radius <= 0 ) {
				continue;
			}
			$minimum = isset( $zone['minimum'] ) ? (float) $zone['minimum'] : ( isset( $zone[' minimum'] ) ? (float) $zone[' minimum'] : 0.0 );
			$fee     = isset( $zone['fee'] ) ? (float) $zone['fee'] : 0.0;
			$label   = isset( $zone['label'] ) ? sanitize_text_field( (string) $zone['label'] ) : '';

			$sanitized[] = array(
				'radius'  => max( 0.0, $radius ),
				'minimum' => max( 0.0, $minimum ),
				'fee'     => max( 0.0, $fee ),
				'label'   => $label,
			);
		}

		if ( empty( $sanitized ) ) {
			return array();
		}

		usort(
			$sanitized,
			static function ( $a, $b ) {
				$radius_compare = $a['radius'] <=> $b['radius'];
				if ( 0 !== $radius_compare ) {
					return $radius_compare;
				}
				return $a['minimum'] <=> $b['minimum'];
			}
		);

		return array_values( $sanitized );
	}

	/**
	 * Resolve the active radius zone for a distance and cart amount.
	 *
	 * When multiple zones share the same radius, the lowest minimum remains the
	 * order minimum and the cart amount selects the highest reached fee tier.
	 *
	 * @param float      $distance_km     Customer distance in kilometers.
	 * @param float|null $cart_subtotal   Cart contents total after discounts.
	 * @param array|null $radius_settings Optional preloaded radius settings.
	 * @return array{radius: float, minimum: float, fee: float, label: string, tier_minimum: float, distance: float}|null
	 */
	public static function resolve_radius_zone_for_distance( float $distance_km, ?float $cart_subtotal = null, ?array $radius_settings = null ): ?array {
		if ( $distance_km < 0 ) {
			return null;
		}

		$settings       = is_array( $radius_settings ) ? $radius_settings : self::get_radius_settings();
		$distance_limit = isset( $settings['distance'] ) ? (float) $settings['distance'] : 0.0;
		$zones          = isset( $settings['zones'] ) && is_array( $settings['zones'] ) ? self::sanitize_radius_zones( $settings['zones'] ) : array();

		if ( empty( $zones ) && $distance_limit > 0 ) {
			$zones = array(
				array(
					'radius'  => $distance_limit,
					'minimum' => isset( $settings['minimum'] ) ? max( 0.0, (float) $settings['minimum'] ) : 0.0,
					'fee'     => isset( $settings['fee'] ) ? max( 0.0, (float) $settings['fee'] ) : 0.0,
					'label'   => '',
				),
			);
		}

		if ( empty( $zones ) ) {
			return null;
		}

		$matched_radius = null;
		foreach ( $zones as $zone ) {
			$radius = isset( $zone['radius'] ) ? (float) $zone['radius'] : 0.0;
			if ( $radius <= 0 ) {
				continue;
			}
			if ( $distance_km <= $radius ) {
				$matched_radius = $radius;
				break;
			}
		}

		if ( null === $matched_radius ) {
			return null;
		}

		$tiers = array_values(
			array_filter(
				$zones,
				static function ( $zone ) use ( $matched_radius ) {
					$radius = isset( $zone['radius'] ) ? (float) $zone['radius'] : 0.0;
					return abs( $radius - $matched_radius ) <= 0.001;
				}
			)
		);

		if ( empty( $tiers ) ) {
			return null;
		}

		usort(
			$tiers,
			static function ( $a, $b ) {
				$minimum_a = isset( $a['minimum'] ) ? (float) $a['minimum'] : 0.0;
				$minimum_b = isset( $b['minimum'] ) ? (float) $b['minimum'] : 0.0;
				return $minimum_a <=> $minimum_b;
			}
		);

		$cart_subtotal = null === $cart_subtotal ? self::get_cart_subtotal_for_delivery_tiers() : max( 0.0, $cart_subtotal );
		$base_tier     = $tiers[0];
		$selected_tier = $base_tier;

		foreach ( $tiers as $tier ) {
			$tier_minimum = isset( $tier['minimum'] ) ? max( 0.0, (float) $tier['minimum'] ) : 0.0;
			if ( $cart_subtotal >= $tier_minimum ) {
				$selected_tier = $tier;
			}
		}

		return array(
			'radius'       => $matched_radius,
			'minimum'      => isset( $base_tier['minimum'] ) ? max( 0.0, (float) $base_tier['minimum'] ) : 0.0,
			'fee'          => isset( $selected_tier['fee'] ) ? max( 0.0, (float) $selected_tier['fee'] ) : 0.0,
			'label'        => isset( $selected_tier['label'] ) ? (string) $selected_tier['label'] : '',
			'tier_minimum' => isset( $selected_tier['minimum'] ) ? max( 0.0, (float) $selected_tier['minimum'] ) : 0.0,
			'distance'     => $distance_km,
		);
	}

	/**
	 * Resolve the current session radius zone using the active cart amount.
	 *
	 * @param WC_Cart|null $cart Optional WooCommerce cart instance.
	 * @return array{radius: float, minimum: float, fee: float, label: string, tier_minimum: float, distance: float}|null
	 */
	public static function resolve_current_radius_zone_for_cart( $cart = null ): ?array {
		if ( 'radius' !== self::get_delivery_mode() ) {
			return null;
		}

		$session = self::get_session();
		if ( empty( $session['allowed'] ) ) {
			return null;
		}

		$settings   = self::get_radius_settings();
		$center_lat = isset( $settings['center_lat'] ) ? $settings['center_lat'] : null;
		$center_lng = isset( $settings['center_lng'] ) ? $settings['center_lng'] : null;
		$lat        = isset( $session['lat'] ) ? $session['lat'] : null;
		$lng        = isset( $session['lng'] ) ? $session['lng'] : null;
		$distance   = null;

		if ( null !== $center_lat && null !== $center_lng && null !== $lat && null !== $lng ) {
			$distance = self::calculate_distance_km( (float) $center_lat, (float) $center_lng, (float) $lat, (float) $lng );
		} elseif ( ! empty( $session['zone_radius'] ) ) {
			$distance = (float) $session['zone_radius'];
		}

		if ( null === $distance ) {
			return null;
		}

		return self::resolve_radius_zone_for_distance( $distance, self::get_cart_subtotal_for_delivery_tiers( $cart ), $settings );
	}

	/**
	 * Sync the current radius session fee/minimum from the active cart tier.
	 *
	 * @param WC_Cart|null $cart Optional WooCommerce cart instance.
	 * @return array{radius: float, minimum: float, fee: float, label: string, tier_minimum: float, distance: float}|null
	 */
	public static function sync_current_radius_zone_for_cart( $cart = null ): ?array {
		$zone = self::resolve_current_radius_zone_for_cart( $cart );
		if ( ! is_array( $zone ) ) {
			return null;
		}

		$session = self::get_session();
		self::persist_session(
			(string) ( $session['city'] ?? '' ),
			(string) ( $session['postal'] ?? '' ),
			true,
			(float) $zone['fee'],
			(float) $zone['minimum'],
			array(
				'address'     => (string) ( $session['address'] ?? '' ),
				'lat'         => $session['lat'] ?? null,
				'lng'         => $session['lng'] ?? null,
				'zone_radius' => (float) $zone['radius'],
			)
		);

		return $zone;
	}

	/**
	 * Read cart contents total used by radius fee tiers.
	 *
	 * @param WC_Cart|null $cart Optional WooCommerce cart instance.
	 * @return float
	 */
	public static function get_cart_subtotal_for_delivery_tiers( $cart = null ): float {
		if ( null === $cart && function_exists( 'WC' ) && WC()->cart ) {
			$cart = WC()->cart;
		}

		if ( ! $cart || ! method_exists( $cart, 'get_cart_contents_total' ) ) {
			return 0.0;
		}

		return max( 0.0, (float) $cart->get_cart_contents_total() );
	}

	/**
	 * Calculate distance between two coordinates in kilometers.
	 *
	 * @param float $lat1 Latitude 1.
	 * @param float $lng1 Longitude 1.
	 * @param float $lat2 Latitude 2.
	 * @param float $lng2 Longitude 2.
	 * @return float
	 */
	private static function calculate_distance_km( float $lat1, float $lng1, float $lat2, float $lng2 ): float {
		$earth_radius = 6371.0;
		$lat_delta    = deg2rad( $lat2 - $lat1 );
		$lng_delta    = deg2rad( $lng2 - $lng1 );
		$a            = sin( $lat_delta / 2 ) * sin( $lat_delta / 2 ) + cos( deg2rad( $lat1 ) ) * cos( deg2rad( $lat2 ) ) * sin( $lng_delta / 2 ) * sin( $lng_delta / 2 );
		$c            = 2 * atan2( sqrt( $a ), sqrt( 1 - $a ) );

		return $earth_radius * $c;
	}

	/**
	 * Ensure radius center coordinates are available, geocoding if necessary.
	 *
	 * @param string $maps_api_key Google Maps API key.
	 * @return array{lat: float|null, lng: float|null}
	 */
	public static function ensure_radius_center_coordinates( string $maps_api_key = '' ): array {
		$lat_opt              = get_option( 'foodord_delivery_radius_center_lat', '' );
		$lng_opt              = get_option( 'foodord_delivery_radius_center_lng', '' );
		$current_parts        = self::get_store_address_parts();
		$current_address      = implode( ', ', $current_parts );
		$current_address_hash = '' !== $current_address ? md5( strtolower( $current_address ) ) : '';
		$stored_address_hash  = get_option( 'foodord_delivery_radius_address_hash', '' );

		if ( '' !== $lat_opt && '' !== $lng_opt && is_numeric( $lat_opt ) && is_numeric( $lng_opt ) && '' !== $current_address_hash && $stored_address_hash === $current_address_hash ) {
			return array(
				'lat' => (float) $lat_opt,
				'lng' => (float) $lng_opt,
			);
		}

		if ( '' === $maps_api_key ) {
			$maps_api_key = (string) get_option( 'foodord_maps_api_key', '' );
		}
		if ( '' === $maps_api_key ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		if ( empty( $current_parts ) ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		$endpoint = add_query_arg(
			array(
				'address'  => $current_address,
				'key'      => $maps_api_key,
				'language' => self::get_google_maps_language( 'geocode' ),
			),
			'https://maps.googleapis.com/maps/api/geocode/json'
		);

		$response = wp_remote_get(
			$endpoint,
			array(
				'timeout' => 10,
			)
		);

		if ( is_wp_error( $response ) ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		$code = wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		$body = wp_remote_retrieve_body( $response );
		if ( ! $body ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		$data = json_decode( $body, true );
		if ( ! is_array( $data ) || empty( $data['results'] ) || ! isset( $data['results'][0]['geometry']['location'] ) ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		$location = $data['results'][0]['geometry']['location'];
		$lat      = isset( $location['lat'] ) ? (float) $location['lat'] : null;
		$lng      = isset( $location['lng'] ) ? (float) $location['lng'] : null;

		if ( null === $lat || null === $lng ) {
			return array(
				'lat' => null,
				'lng' => null,
			);
		}

		update_option( 'foodord_delivery_radius_center_lat', (string) $lat );
		update_option( 'foodord_delivery_radius_center_lng', (string) $lng );
		if ( '' !== $current_address_hash ) {
			update_option( 'foodord_delivery_radius_address_hash', $current_address_hash );
		}

		return array(
			'lat' => $lat,
			'lng' => $lng,
		);
	}

	/**
	 * Get formatted store address string.
	 *
	 * @return string
	 */
	public static function get_store_address_string(): string {
		$parts = self::get_store_address_parts();
		if ( empty( $parts ) ) {
			return __( 'Not configured', 'foodord' );
		}
		return implode( ', ', $parts );
	}

	/**
	 * Get store address parts from WooCommerce settings.
	 *
	 * @return array
	 */
	public static function get_store_address_parts(): array {
		$parts     = array();
		$address_1 = get_option( 'woocommerce_store_address', '' );
		$address_2 = get_option( 'woocommerce_store_address_2', '' );
		$city      = get_option( 'woocommerce_store_city', '' );
		$postcode  = get_option( 'woocommerce_store_postcode', '' );

		if ( '' !== $address_1 ) {
			$parts[] = $address_1;
		}
		if ( '' !== $address_2 ) {
			$parts[] = $address_2;
		}
		if ( '' !== $city ) {
			$parts[] = $city;
		}
		if ( '' !== $postcode ) {
			$parts[] = $postcode;
		}

		$base_location = function_exists( 'wc_get_base_location' ) ? wc_get_base_location() : array();
		$country_code  = isset( $base_location['country'] ) ? $base_location['country'] : '';
		$state_code    = isset( $base_location['state'] ) ? $base_location['state'] : '';

		$countries_obj = null;
		if ( function_exists( 'WC' ) && WC() && isset( WC()->countries ) ) {
			$countries_obj = WC()->countries;
		} elseif ( class_exists( 'WC_Countries' ) ) {
			$countries_obj = new WC_Countries();
		}

		if ( $countries_obj ) {
			if ( $state_code ) {
				$states = $countries_obj->get_states( $country_code );
				if ( is_array( $states ) && isset( $states[ $state_code ] ) ) {
					$parts[] = $states[ $state_code ];
				} elseif ( '' !== $state_code ) {
					$parts[] = $state_code;
				}
			} elseif ( '' !== $state_code ) {
				$parts[] = $state_code;
			}

			if ( $country_code && isset( $countries_obj->countries[ $country_code ] ) ) {
				$parts[] = $countries_obj->countries[ $country_code ];
			} elseif ( '' !== $country_code ) {
				$parts[] = $country_code;
			}
		} else {
			if ( '' !== $state_code ) {
				$parts[] = $state_code;
			}
			if ( '' !== $country_code ) {
				$parts[] = $country_code;
			}
		}

		return array_values(
			array_filter(
				array_map( 'trim', $parts ),
				static function ( $value ) {
					return '' !== $value;
				}
			)
		);
	}

	/**
	 * Compute fee for a given city (and optional postal) using configured locations.
	 *
	 * @param string $city City.
	 * @param string $postal Postal code (optional).
	 * @return float Fee amount (>= 0).
	 */
	public static function fee_for_city_postal( string $city, string $postal = '' ): float {
		$match = self::find_location( $city, $postal );
		if ( ! is_array( $match ) ) {
			return 0.0;
		}
		$raw = isset( $match['fee'] ) ? (string) $match['fee'] : '0';
		if ( function_exists( 'wc_format_decimal' ) ) {
			return max( 0.0, (float) wc_format_decimal( $raw ) );
		}
		return max( 0.0, (float) $raw );
	}

	/**
	 * Compute fee from WooCommerce customer address (shipping first, then billing).
	 *
	 * @return float Fee amount (>= 0).
	 */
	public static function fee_from_customer_address(): float {
		$city   = '';
		$postal = '';
		try {
			if ( function_exists( 'WC' ) && WC()->customer ) {
				$city   = (string) WC()->customer->get_shipping_city();
				$postal = (string) WC()->customer->get_shipping_postcode();
				if ( '' === $city || '' === $postal ) {
					$city   = (string) WC()->customer->get_billing_city();
					$postal = (string) WC()->customer->get_billing_postcode();
				}
			}
		} catch ( \Throwable $e ) {
			$city   = $city ? $city : '';
			$postal = $postal ? $postal : '';
		}
		if ( '' === $city ) {
			return 0.0;
		}
		return self::fee_for_city_postal( $city, $postal );
	}

	/**
	 * Get configured delivery locations from options.
	 *
	 * @return array[] List of [ city, postal_code (optional), fee ].
	 */
	public static function get_locations(): array {
		$locations = get_option( 'foodord_delivery_locations', array() );
		return is_array( $locations ) ? $locations : array();
	}

	/**
	 * Get current delivery rules version. Increments when admin updates Location Based settings.
	 *
	 * @return int
	 */
	public static function get_rules_version(): int {
		return (int) get_option( 'foodord_delivery_rules_version', 0 );
	}

	/**
	 * Normalize a city string for case-insensitive comparisons.
	 *
	 * @param string $city City name.
	 * @return string Normalized city.
	 */
	public static function normalize_city( string $city ): string {
		$city = trim( wp_strip_all_tags( $city ) );
		if ( function_exists( 'mb_strtolower' ) ) {
			return mb_strtolower( $city, 'UTF-8' );
		}
		return strtolower( $city );
	}

	/**
	 * Normalize a postal code string (trim only; do not force numeric since some locales use letters).
	 *
	 * @param string $postal Postal code.
	 * @return string Normalized postal code.
	 */
	public static function normalize_postal( string $postal ): string {
		$postal = trim( wp_strip_all_tags( $postal ) );
		// Remove spaces, hyphens and underscores for robust matching (storage may vary).
		$postal = preg_replace( '/[\s\-_]+/', '', $postal );
		return $postal;
	}

	/**
	 * Try to find a configured location matching the given city and postal code.
	 *
	 * @param string $city City provided by user.
	 * @param string $postal Postal code provided by user.
	 * @return array|null Matching location array or null when not found.
	 */
	public static function find_location( string $city, string $postal = '' ): ?array {
		$needle_city   = self::normalize_city( $city );
		$needle_postal = self::normalize_postal( $postal );

		if ( '' === $needle_city ) {
			return null;
		}

		$locations = self::get_locations();
		if ( empty( $locations ) || ! is_array( $locations ) ) {
			return null;
		}

		if ( '' !== $needle_postal ) {
			foreach ( $locations as $loc ) {
				if ( ! is_array( $loc ) ) {
					continue;
				}
				$loc_city   = isset( $loc['city'] ) ? self::normalize_city( (string) $loc['city'] ) : '';
				$loc_postal = isset( $loc['postal_code'] ) ? self::normalize_postal( (string) $loc['postal_code'] ) : ( isset( $loc['postalCode'] ) ? self::normalize_postal( (string) $loc['postalCode'] ) : '' );

				if ( '' === $loc_city || '' === $loc_postal ) {
					continue;
				}

				if ( $loc_city === $needle_city && $loc_postal === $needle_postal ) {
					return $loc;
				}
			}
		}

		foreach ( $locations as $loc ) {
			if ( ! is_array( $loc ) ) {
				continue;
			}
			$loc_city = isset( $loc['city'] ) ? self::normalize_city( (string) $loc['city'] ) : '';
			if ( '' === $loc_city ) {
				continue;
			}
			if ( $loc_city === $needle_city ) {
				return $loc;
			}
		}

		return null;
	}

	/**
	 * Persist selection into WooCommerce session.
	 *
	 * @param string     $city     City.
	 * @param string     $postal   Postal code.
	 * @param bool       $allowed  Whether address is deliverable.
	 * @param float      $fee      Delivery fee for the matched location.
	 * @param float|null $minimum  Minimum order amount (null when unknown).
	 * @param array      $extra    Optional extra data (address, lat, lng, zone_radius).
	 * @return void
	 */
	public static function persist_session( string $city, string $postal, bool $allowed, float $fee = 0.0, ?float $minimum = null, array $extra = array() ): void {
		// If not allowed, do not keep arbitrary user input in session.
		if ( ! $allowed ) {
			$city    = '';
			$postal  = '';
			$fee     = 0.0;
			$minimum = 0.0;
			$extra   = array();
		}
		if ( function_exists( 'WC' ) && WC()->session ) {
			$session = WC()->session;
			if ( method_exists( $session, 'has_session' ) && ! $session->has_session() && method_exists( $session, 'set_customer_session_cookie' ) ) {
				$session->set_customer_session_cookie( true );
			}
			$session->set( self::SESSION_CITY_KEY, $city );
			$session->set( self::SESSION_POSTAL_KEY, $postal );
			$session->set( self::SESSION_ALLOWED_KEY, $allowed ? 'yes' : 'no' );
			$session->set( self::SESSION_FEE_KEY, $fee );
			if ( null === $minimum ) {
				$minimum = 0.0;
			}
			$session->set( self::SESSION_MINIMUM_KEY, max( 0.0, (float) $minimum ) );
			if ( isset( $extra['address'] ) ) {
				$session->set( self::SESSION_ADDRESS_KEY, (string) $extra['address'] );
			}
			if ( isset( $extra['lat'] ) && is_numeric( $extra['lat'] ) ) {
				$session->set( self::SESSION_LAT_KEY, (float) $extra['lat'] );
			}
			if ( isset( $extra['lng'] ) && is_numeric( $extra['lng'] ) ) {
				$session->set( self::SESSION_LNG_KEY, (float) $extra['lng'] );
			}
			if ( isset( $extra['zone_radius'] ) && is_numeric( $extra['zone_radius'] ) ) {
				$session->set( self::SESSION_ZONE_RADIUS_KEY, max( 0.0, (float) $extra['zone_radius'] ) );
			} else {
				$session->set( self::SESSION_ZONE_RADIUS_KEY, 0.0 );
			}
			$session->set( self::SESSION_RULES_VERSION_KEY, self::get_rules_version() );
			if ( method_exists( $session, 'save_data' ) ) {
				$session->save_data();
			}
		}
	}

	/**
	 * Clear persisted selection.
	 *
	 * @return void
	 */
	public static function clear_session(): void {
		if ( function_exists( 'WC' ) && WC()->session ) {
			WC()->session->__unset( self::SESSION_CITY_KEY );
			WC()->session->__unset( self::SESSION_POSTAL_KEY );
			WC()->session->__unset( self::SESSION_ALLOWED_KEY );
			WC()->session->__unset( self::SESSION_FEE_KEY );
			WC()->session->__unset( self::SESSION_MINIMUM_KEY );
			WC()->session->__unset( self::SESSION_RULES_VERSION_KEY );
			WC()->session->__unset( self::SESSION_ADDRESS_KEY );
			WC()->session->__unset( self::SESSION_LAT_KEY );
			WC()->session->__unset( self::SESSION_LNG_KEY );
			WC()->session->__unset( self::SESSION_ZONE_RADIUS_KEY );
		}
	}

	/**
	 * Read session snapshot.
	 *
	 * @return array{city: string, postal: string, allowed: bool, fee: float, minimum: float, address: string, lat: float|null, lng: float|null, zone_radius: float}
	 */
	public static function get_session(): array {
		$city        = '';
		$postal      = '';
		$address     = '';
		$allowed     = false;
		$fee         = 0.0;
		$minimum     = 0.0;
		$latitude    = null;
		$longitude   = null;
		$zone_radius = 0.0;

		if ( function_exists( 'WC' ) && WC()->session ) {
			$city = (string) WC()->session->get( self::SESSION_CITY_KEY, '' );

			$postal = (string) WC()->session->get( self::SESSION_POSTAL_KEY, '' );

			$allowed = 'yes' === (string) WC()->session->get( self::SESSION_ALLOWED_KEY, 'no' );

			$fee         = (float) WC()->session->get( self::SESSION_FEE_KEY, 0.0 );
			$minimum     = (float) WC()->session->get( self::SESSION_MINIMUM_KEY, 0.0 );
			$address     = (string) WC()->session->get( self::SESSION_ADDRESS_KEY, '' );
			$lat_val     = WC()->session->get( self::SESSION_LAT_KEY, null );
			$lng_val     = WC()->session->get( self::SESSION_LNG_KEY, null );
			$zone_radius = (float) WC()->session->get( self::SESSION_ZONE_RADIUS_KEY, 0.0 );
			if ( null !== $lat_val && is_numeric( $lat_val ) ) {
				$latitude = (float) $lat_val;
			}
			if ( null !== $lng_val && is_numeric( $lng_val ) ) {
				$longitude = (float) $lng_val;
			}

			$session_rules = (int) WC()->session->get( self::SESSION_RULES_VERSION_KEY, 0 );

			$current_rules = self::get_rules_version();
			if ( $current_rules > 0 && $session_rules !== $current_rules ) {
				// Rules updated in admin: re-evaluate current session address if present; otherwise clear.
				if ( '' !== $city ) {
					$match = self::find_location( $city, $postal );
					if ( $match ) {
						$raw_fee = isset( $match['fee'] ) ? (string) $match['fee'] : '';
						$raw_min = isset( $match['minimum'] ) ? (string) $match['minimum'] : '';
						$fee     = function_exists( 'wc_format_decimal' ) ? (float) wc_format_decimal( $raw_fee ) : (float) $raw_fee;
						$fee     = max( 0.0, $fee );
						if ( '' !== $raw_min ) {
							$minimum = function_exists( 'wc_format_decimal' ) ? (float) wc_format_decimal( $raw_min ) : (float) $raw_min;
						}
						$minimum        = max( 0.0, $minimum );
						$matched_postal = '';
						if ( isset( $match['postal_code'] ) && '' !== $match['postal_code'] ) {
							$matched_postal = (string) $match['postal_code'];
						} elseif ( isset( $match['postalCode'] ) && '' !== $match['postalCode'] ) {
							$matched_postal = (string) $match['postalCode'];
						}
						if ( '' !== $matched_postal ) {
							$postal = $matched_postal;
						}
						$allowed = true;
					} else {
						$allowed = false;
						$fee     = 0.0;
						$minimum = 0.0;
						$postal  = '';
					}
					self::persist_session( $city, $postal, $allowed, $fee, $minimum );
				} else {
					self::clear_session();
					$allowed = false;
					$city    = '';
					$postal  = '';
					$fee     = 0.0;
					$minimum = 0.0;
				}
			}
		}

		// As a safeguard, if not allowed then do not expose stale city/postal.
		if ( ! $allowed ) {
			$city    = '';
			$postal  = '';
			$fee     = 0.0;
			$minimum = 0.0;
		}

		return array(
			'city'        => $city,
			'postal'      => $postal,
			'address'     => $address,
			'lat'         => $latitude,
			'lng'         => $longitude,
			'zone_radius' => (float) $zone_radius,
			'allowed'     => (bool) $allowed,
			'fee'         => (float) $fee,
			'minimum'     => (float) $minimum,
		);
	}

	/**
	 * Ensure session reflects currently posted checkout values (optional helper).
	 *
	 * @param array $data Checkout posted data.
	 * @return void
	 */
	public static function maybe_sync_from_checkout_post( array $data ): void {
		$city   = isset( $data['billing_city'] ) ? (string) $data['billing_city'] : '';
		$postal = isset( $data['billing_postcode'] ) ? (string) $data['billing_postcode'] : '';
		if ( '' === $city ) {
			return;
		}
		$match = self::find_location( $city, $postal );
		if ( $match ) {
			$fee     = 0.0;
			$minimum = 0.0;
			$raw_fee = isset( $match['fee'] ) ? (string) $match['fee'] : '';
			$raw_min = isset( $match['minimum'] ) ? (string) $match['minimum'] : '';
			if ( function_exists( 'wc_format_decimal' ) ) {
				$fee = (float) wc_format_decimal( $raw_fee );
				if ( '' !== $raw_min ) {
					$minimum = (float) wc_format_decimal( $raw_min );
				}
			} else {
				$fee = (float) $raw_fee;
				if ( '' !== $raw_min ) {
					$minimum = (float) $raw_min;
				}
			}
			$matched_postal = '';
			if ( isset( $match['postal_code'] ) && '' !== $match['postal_code'] ) {
				$matched_postal = (string) $match['postal_code'];
			} elseif ( isset( $match['postalCode'] ) && '' !== $match['postalCode'] ) {
				$matched_postal = (string) $match['postalCode'];
			}
			$resolved_postal = '' !== $matched_postal ? $matched_postal : $postal;
			self::persist_session( $city, $resolved_postal, true, max( 0.0, $fee ), max( 0.0, $minimum ) );
			return;
		}
		self::persist_session( $city, $postal, false, 0.0, 0.0 );
	}
}
