<?php
/**
 * Service that suggests a single shipping package for each order in a bulk
 * label-purchase request, using the merchant's configured packages and the
 * `woocommerce/box-packer` library.
 *
 * @package Automattic\WCShipping\PackageAssignment
 */

namespace Automattic\WCShipping\PackageAssignment;

use Automattic\WCShipping\Connect\WC_Connect_Logger;
use Automattic\WCShipping\Connect\WC_Connect_Service_Schemas_Store;
use Automattic\WCShipping\Connect\WC_Connect_Service_Settings_Store;
use WooCommerce\BoxPacker\WC_Boxpack;
use WC_Order;

defined( 'ABSPATH' ) || exit;

/**
 * Resolves a suggested package for each order in a bulk request.
 */
class PackageAssignmentService {

	/** Status: a single configured package can hold every shippable item in the order. */
	const STATUS_FIT = 'fit';
	/** Status: items would need more than one package, or a single item is too large for any configured package. */
	const STATUS_NEEDS_SPLIT = 'needs_split';
	/** Status: at least one shippable product is missing length, width, height, or weight. */
	const STATUS_MISSING_DIMENSIONS = 'missing_dimensions';
	/** Status: the store has no configured packages to choose from. */
	const STATUS_NO_PACKAGES = 'no_packages';
	/** Status: the order has no items requiring shipping (e.g. all items are virtual or downloadable). */
	const STATUS_NO_SHIPPABLE_ITEMS = 'no_shippable_items';
	/** Status: an unexpected condition prevented the service from producing a suggestion. */
	const STATUS_ERROR = 'error';

	/**
	 * Settings store providing the merchant's package configuration.
	 *
	 * @var WC_Connect_Service_Settings_Store
	 */
	private $settings_store;

	/**
	 * Schemas store used to resolve enabled predefined package IDs against
	 * the cached predefined-packages schema.
	 *
	 * @var WC_Connect_Service_Schemas_Store
	 */
	private $service_schemas_store;

	/**
	 * Logger used to record skipped orders and unexpected packer failures.
	 *
	 * @var WC_Connect_Logger
	 */
	private $logger;

	/**
	 * Constructor.
	 *
	 * @param WC_Connect_Service_Settings_Store $settings_store        Settings store.
	 * @param WC_Connect_Service_Schemas_Store  $service_schemas_store Schemas store.
	 * @param WC_Connect_Logger                 $logger                Logger.
	 */
	public function __construct(
		WC_Connect_Service_Settings_Store $settings_store,
		WC_Connect_Service_Schemas_Store $service_schemas_store,
		WC_Connect_Logger $logger
	) {
		$this->settings_store        = $settings_store;
		$this->service_schemas_store = $service_schemas_store;
		$this->logger                = $logger;
	}

	/**
	 * Suggest a package for each order id.
	 *
	 * @param int[] $order_ids Order ids to process.
	 *
	 * @return array<int, array{status: string, package_id?: string, package_name?: string, service_id?: string, reason?: string}> Map of order id to assignment result. `service_id` is only set when the picked package is a predefined carrier package.
	 */
	public function assign_for_orders( array $order_ids ): array {
		// Read the canonical sources once per batch. `assign_for_orders()`
		// uses them for the "no packages configured" fast-path and then
		// hands them to `build_configured_packages()`, which used to read
		// the same options a second time. One read per option per batch is
		// enough and avoids a small race if the option changes mid-request.
		$custom_packages    = $this->settings_store->get_packages();
		$enabled_predefined = $this->settings_store->get_predefined_packages();

		// Schema cache lookup is the only remaining bulk read tied to this
		// batch. Fetch it once here and pass it into the helper instead of
		// re-fetching inside `build_configured_packages()`.
		$schema = $this->service_schemas_store->get_predefined_packages_schema();

		// Mirror the canonical "no packages configured" check used by
		// EligibilityRESTController so the fast-path response matches what
		// other label-flow gates already report. We must not treat schema
		// definitions cached on the server as "configured packages" here.
		// `get_predefined_packages()` returns a map of service id to id list
		// (e.g. `[ 'usps' => [], 'fedex' => [] ]`), which is non-empty as a
		// map even when every inner list is empty. The helper walks the
		// inner lists so the fast-path correctly recognizes "zero enabled
		// predefined ids".
		$has_enabled_predef = self::has_enabled_predefined( $enabled_predefined );

		$has_no_packages = empty( $custom_packages ) && ! $has_enabled_predef;

		if ( $has_no_packages ) {
			$no_packages = array(
				'status' => self::STATUS_NO_PACKAGES,
				'reason' => __( 'No packages are configured for this store.', 'woocommerce-shipping' ),
			);

			$results = array();
			foreach ( $order_ids as $order_id ) {
				$results[ $order_id ] = $no_packages;
			}
			return $results;
		}

		// Build the normalized package list once for the whole batch. The
		// per-order method uses this list verbatim, so a 25-order batch
		// walks the schema, parses dimensions, and applies the type filter
		// only once instead of N times.
		$built                     = $this->build_configured_packages(
			is_array( $custom_packages ) ? $custom_packages : array(),
			is_array( $enabled_predefined ) ? $enabled_predefined : array(),
			$schema
		);
		$configured_packages       = $built['packages'];
		$had_box_candidate         = $built['had_box_candidate'];
		$had_unresolved_predefined = $built['had_unresolved_predefined'];
		$results                   = array();

		if ( empty( $configured_packages ) ) {
			// Two empty-list shapes need different surfaces. If the merchant
			// configured at least one `type === box` candidate but every box
			// failed to parse/resolve, surface STATUS_ERROR (the fix is to
			// repair the data, not "add a package"). If the only candidates
			// were non-box types filtered out for MVP, surface
			// STATUS_NO_PACKAGES with a reason that points the merchant at
			// the missing box-type entry: the fast-path above has already
			// handled the "nothing saved at all" case, so reaching here with
			// no box candidate means everything saved was an envelope or
			// other non-box type and adding another non-box one would not
			// help.
			$has_unusable_configured_packages = $had_box_candidate || $had_unresolved_predefined;
			if ( $has_unusable_configured_packages ) {
				$reason = __( 'All configured packages have invalid dimensions or could not be resolved.', 'woocommerce-shipping' );
			} else {
				$reason = __( 'No box-type packages are configured. Add a box-type package in Settings → Shipping → Packages.', 'woocommerce-shipping' );
			}

			$status = $has_unusable_configured_packages ? self::STATUS_ERROR : self::STATUS_NO_PACKAGES;

			foreach ( $order_ids as $order_id ) {
				$results[ $order_id ] = array(
					'status' => $status,
					'reason' => $reason,
				);
			}
			return $results;
		}

		foreach ( $order_ids as $order_id ) {
			$results[ $order_id ] = $this->assign_for_order( (int) $order_id, $configured_packages );
		}

		return $results;
	}

	/**
	 * Suggest a package for a single order.
	 *
	 * @param int   $order_id            Order id.
	 * @param array $configured_packages Normalized merchant package list produced by `build_configured_packages()`. Built once per batch in `assign_for_orders()` and passed through verbatim, so this method does not re-walk the schema.
	 *
	 * @return array{status: string, package_id?: string, package_name?: string, service_id?: string, reason?: string} `service_id` is only set when the picked package is a predefined carrier package.
	 */
	private function assign_for_order( int $order_id, array $configured_packages ): array {
		$order = wc_get_order( $order_id );
		if ( ! $order instanceof WC_Order ) {
			$this->logger->log(
				sprintf( 'Auto-assign skipped order: not found (order_id=%d)', $order_id ),
				__CLASS__
			);
			return array(
				'status' => self::STATUS_ERROR,
				'reason' => __( 'Order not found.', 'woocommerce-shipping' ),
			);
		}

		$shippable_items = $this->get_shippable_items( $order );
		if ( empty( $shippable_items ) ) {
			return array(
				'status' => self::STATUS_NO_SHIPPABLE_ITEMS,
				'reason' => __( 'Order has no items that need shipping.', 'woocommerce-shipping' ),
			);
		}

		$missing = $this->find_item_with_invalid_metrics( $shippable_items );
		if ( null !== $missing ) {
			return array(
				'status' => self::STATUS_MISSING_DIMENSIONS,
				'reason' => sprintf(
					/* translators: %s: product name */
					__( 'Product "%s" is missing length, width, height, or weight.', 'woocommerce-shipping' ),
					$missing
				),
			);
		}

		$store_dim_unit    = get_option( 'woocommerce_dimension_unit', 'cm' );
		$store_weight_unit = get_option( 'woocommerce_weight_unit', 'kg' );

		$boxpack = ( new WC_Boxpack( $store_dim_unit, $store_weight_unit, 'dvdoug' ) )->get_packer();

		foreach ( $configured_packages as $package ) {
			$box = $boxpack->add_box(
				(float) $package['length'],
				(float) $package['width'],
				(float) $package['height'],
				(float) ( $package['box_weight'] ?? 0 )
			);
			$box->set_max_weight( (float) ( $package['max_weight'] ?? 0 ) );
			$box->set_id( (string) $package['id'] );
		}

		foreach ( $shippable_items as $item_data ) {
			$product  = $item_data['product'];
			$quantity = $item_data['quantity'];
			$boxpack->add_item(
				(float) $product->get_length(),
				(float) $product->get_width(),
				(float) $product->get_height(),
				(float) $product->get_weight(),
				(float) $product->get_price(),
				array(),
				$quantity
			);
		}

		try {
			$packed = $this->pack_with_boxpack( $boxpack, $order_id );
		} catch ( \Throwable $e ) {
			$this->logger->log(
				sprintf(
					'Auto-assign packer threw %s for order_id=%d: %s',
					get_class( $e ),
					$order_id,
					$e->getMessage()
				),
				__CLASS__
			);
			return array(
				'status' => self::STATUS_ERROR,
				'reason' => __( 'An unexpected error occurred while suggesting a package.', 'woocommerce-shipping' ),
			);
		}

		$count = count( $packed );

		if ( 0 === $count ) {
			$this->logger->log(
				sprintf( 'Auto-assign produced no packages (order_id=%d)', $order_id ),
				__CLASS__
			);
			return array(
				'status' => self::STATUS_ERROR,
				'reason' => __( 'No packages were produced by the packer.', 'woocommerce-shipping' ),
			);
		}

		// InfalliblePacker marks oversize items via the unpacked property on the
		// emitted Package. If any result is unpacked, no configured box can hold
		// at least one item. Surface this as needs_split rather than a misleading
		// fit with an empty package_id.
		foreach ( $packed as $pkg ) {
			if ( ! empty( $pkg->unpacked ) ) {
				return array(
					'status' => self::STATUS_NEEDS_SPLIT,
					'reason' => __( 'An item is too large for any configured package.', 'woocommerce-shipping' ),
				);
			}
		}

		if ( $count >= 2 ) {
			return array(
				'status' => self::STATUS_NEEDS_SPLIT,
				'reason' => __( "This order's items don't all fit in one package. Pick a larger package, or split the order into multiple shipments.", 'woocommerce-shipping' ),
			);
		}

		$picked_internal_id = (string) $packed[0]->id;
		$package            = null;
		foreach ( $configured_packages as $candidate ) {
			if ( (string) $candidate['id'] === $picked_internal_id ) {
				$package = $candidate;
				break;
			}
		}

		if ( null === $package || empty( $package['name'] ) ) {
			$this->logger->log(
				sprintf(
					'Auto-assign packer returned unknown package id "%s" for order_id=%d',
					$picked_internal_id,
					$order_id
				),
				__CLASS__
			);
			return array(
				'status' => self::STATUS_ERROR,
				'reason' => __( 'An unexpected error occurred while suggesting a package.', 'woocommerce-shipping' ),
			);
		}

		$result = array(
			'status'       => self::STATUS_FIT,
			'package_id'   => (string) $package['package_id'],
			'package_name' => (string) $package['name'],
		);

		// Predefined entries carry the carrier scope so REST clients can
		// disambiguate carriers that share a predefined id (e.g. both USPS
		// and FedEx publishing "medium_box"). Custom packages have no
		// service scope, so the field is omitted there.
		if ( ! empty( $package['service_id'] ) ) {
			$result['service_id'] = (string) $package['service_id'];
		}

		return $result;
	}

	/**
	 * Run the boxpacker against the configured boxes/items and return the
	 * resulting list of `WooCommerce\BoxPacker\Package` objects. Wrapped in
	 * an output buffer because the dvdoug packer's
	 * `Abstract_Packer::maybe_display_packing_error()` echoes
	 * "Packing error: ..." straight to stdout for any user with
	 * `manage_options`, which would corrupt the JSON REST response.
	 *
	 * The captured stdout is logged (with the order id for context) instead
	 * of being discarded, so production packing diagnostics survive into the
	 * Connect Server logs without leaking into the REST body.
	 *
	 * Marked `protected` so tests can override it to simulate an unexpected
	 * exception thrown deep in the packer; the production implementation
	 * intentionally does its own error handling so the caller's `\Throwable`
	 * catch only fires on truly unexpected fatal errors.
	 *
	 * @param mixed $boxpack  The packer instance returned by `WC_Boxpack::get_packer()`.
	 * @param int   $order_id Order id, used to tag any captured packer stdout.
	 * @return array
	 * @throws \Throwable Re-thrown after capturing and logging any stdout the packer emitted before failing.
	 */
	protected function pack_with_boxpack( $boxpack, int $order_id = 0 ): array {
		ob_start();
		try {
			$boxpack->pack();
			$packages = $boxpack->get_packages();
		} catch ( \Throwable $e ) {
			$packer_output = (string) ob_get_clean();
			if ( '' !== trim( $packer_output ) ) {
				$this->logger->log(
					sprintf(
						'Auto-assign packer emitted stdout for order_id=%d before throwing: %s',
						$order_id,
						trim( $packer_output )
					),
					__CLASS__
				);
			}
			throw $e;
		}

		$packer_output = (string) ob_get_clean();
		if ( '' !== trim( $packer_output ) ) {
			$this->logger->log(
				sprintf(
					'Auto-assign packer emitted stdout for order_id=%d: %s',
					$order_id,
					trim( $packer_output )
				),
				__CLASS__
			);
		}

		return $packages;
	}

	/**
	 * Filter order line items to those that need shipping.
	 *
	 * @param WC_Order $order Order instance.
	 *
	 * @return array<int, array{product: \WC_Product, quantity: int}>
	 */
	private function get_shippable_items( WC_Order $order ): array {
		$items = array();
		foreach ( $order->get_items() as $item ) {
			$product = $item->get_product();
			if ( ! $product || ! $product->needs_shipping() ) {
				continue;
			}
			$items[] = array(
				'product'  => $product,
				'quantity' => (int) $item->get_quantity(),
			);
		}
		return $items;
	}

	/**
	 * Build a normalized list of packages the merchant has configured for
	 * label purchase: custom packages saved against the store and predefined
	 * carrier packages explicitly enabled in the store's predefined-package
	 * settings.
	 *
	 * Returns a flat list of `{ id, name, length, width, height, box_weight,
	 * max_weight }` ready to feed into `WC_Boxpack`. Packages whose dimension
	 * string cannot be parsed are skipped (logged via WC_Connect_Logger)
	 * rather than silently coerced to 0x0x0 boxes that would fit anything in
	 * the packer.
	 *
	 * Crucially this does NOT use `WC_Connect_Service_Settings_Store::get_package_lookup()`
	 * because that helper returns every predefined definition from the cached
	 * schema regardless of whether the merchant enabled it. Auto-assign must
	 * only consider packages the merchant actually picked.
	 *
	 * The `id` field is a stable internal id used to look up the picked box
	 * after packing: for custom packages it is `"custom:{package_id}"`, for
	 * predefined packages it is `"predef:{service_id}:{package_id}"`, so two
	 * carriers sharing a predefined id (e.g. both USPS and FedEx defining
	 * `"medium_box"`) cannot collide and a custom id cannot accidentally
	 * shadow a predefined id either. The merchant-facing `package_id`
	 * returned to clients is the original saved id, kept in `package_id`.
	 * Predefined entries also carry `service_id` so callers can scope the
	 * picked id back to its carrier; custom entries leave it null because
	 * custom packages have no service scope.
	 *
	 * Candidates are filtered to `type === 'box'` for MVP. The dvdoug packer
	 * is a rigid-box model and produces false fit results for envelopes,
	 * paks, tubes, etc. Non-box types are skipped silently with a log entry
	 * via WC_Connect_Logger so we can audit the gap later.
	 * TODO(WOOSHIP-2164+): re-add non-box types with type-specific handling.
	 *
	 * The return value also carries a `had_box_candidate` flag: true if the
	 * merchant configured at least one entry whose `type` was `'box'`,
	 * regardless of whether its dimensions later parsed cleanly. The caller
	 * uses this to distinguish "no packages configured for this store"
	 * (true MVP gap) from "boxes were configured but unparseable"
	 * (a data-repair issue).
	 *
	 * `had_unresolved_predefined` is true when the merchant enabled at least
	 * one predefined package id that cannot be found in the cached schema.
	 * That is not the same as "no packages configured"; the saved selection
	 * exists, but the schema needs to be refreshed or repaired.
	 *
	 * The predefined-packages schema is fetched once per batch in
	 * `assign_for_orders()` and passed in via `$schema`.
	 *
	 * The schema can arrive as either nested `stdClass` objects or
	 * associative arrays depending on how the upstream payload was decoded.
	 * Each level is cast to `(array)` before reading so the loop produces
	 * the same candidate set in both shapes. The cast is a no-op when the
	 * value is already an array, and on an object it returns the public
	 * property values keyed by name, which matches the existing object-
	 * access path verbatim for the test fixtures.
	 *
	 * @param array             $custom_packages    Custom packages list as returned by `WC_Connect_Service_Settings_Store::get_packages()`.
	 * @param array             $enabled_predefined Map of service id to enabled predefined ids as returned by `WC_Connect_Service_Settings_Store::get_predefined_packages()`.
	 * @param array|object|null $schema             Predefined packages schema as returned by `WC_Connect_Service_Schemas_Store::get_predefined_packages_schema()`.
	 *
	 * @return array{packages: array<int, array{id: string, package_id: string, service_id: ?string, name: string, length: float, width: float, height: float, box_weight: float, max_weight: float}>, had_box_candidate: bool, had_unresolved_predefined: bool}
	 */
	private function build_configured_packages( array $custom_packages, array $enabled_predefined, $schema ): array {
		$packages                  = array();
		$had_box_candidate         = false;
		$had_unresolved_predefined = false;
		// Track internal ids that have already been registered so a schema
		// listing the same predefined id under multiple groups for one
		// service (or any unexpected collision with a custom id) does not
		// produce two boxes that share an internal id and create
		// iteration-order-dependent results.
		$added_internal = array();

		// Custom packages: dimensions stored as "L x W x H" with camelCase
		// boxWeight/maxWeight. See `Package::to_array()`.
		foreach ( $custom_packages as $custom ) {
			$custom_id   = isset( $custom['id'] ) ? (string) $custom['id'] : '(unknown)';
			$custom_type = isset( $custom['type'] ) ? (string) $custom['type'] : '';
			if ( 'box' !== $custom_type ) {
				$this->logger->log(
					sprintf(
						'Auto-assign skipped custom package "%s": type "%s" is not a box (MVP only supports rigid boxes)',
						$custom_id,
						$custom_type
					),
					__CLASS__
				);
				continue;
			}

			$had_box_candidate = true;

			$dims = $this->parse_dimensions( isset( $custom['dimensions'] ) ? (string) $custom['dimensions'] : '' );
			if ( null === $dims ) {
				$this->logger->log(
					sprintf(
						'Auto-assign skipped custom package "%s": malformed dimensions "%s"',
						$custom_id,
						isset( $custom['dimensions'] ) ? (string) $custom['dimensions'] : ''
					),
					__CLASS__
				);
				continue;
			}

			$internal_id = sprintf( 'custom:%s', $custom_id );
			if ( isset( $added_internal[ $internal_id ] ) ) {
				continue;
			}

			$packages[]                     = array(
				'id'         => $internal_id,
				'package_id' => $custom_id,
				'service_id' => null,
				'name'       => isset( $custom['name'] ) ? (string) $custom['name'] : '',
				'length'     => $dims['length'],
				'width'      => $dims['width'],
				'height'     => $dims['height'],
				'box_weight' => isset( $custom['boxWeight'] ) ? (float) $custom['boxWeight'] : 0.0,
				'max_weight' => isset( $custom['maxWeight'] ) ? (float) $custom['maxWeight'] : 0.0,
			);
			$added_internal[ $internal_id ] = true;
		}

		// Predefined packages: only those the merchant enabled, resolved per
		// service. Two carriers can ship a predefined id with the same string
		// (e.g. "medium_box") but different dimensions; matching by id alone
		// would let one carrier's box leak into the other carrier's slot.
		// Walk the schema service-by-service so each (service_id, package_id)
		// pair is resolved against its own definitions.
		if ( ! empty( $enabled_predefined ) ) {
			if ( empty( $schema ) ) {
				$had_unresolved_predefined = self::has_enabled_predefined( $enabled_predefined );
			} else {
				// Cast to array up front so both `stdClass` and associative
				// shapes share one access path.
				$schema_arr = (array) $schema;
				foreach ( $enabled_predefined as $service_id => $ids ) {
					if ( ! is_array( $ids ) || empty( $ids ) ) {
						continue;
					}

					$service_key = (string) $service_id;
					if ( ! isset( $schema_arr[ $service_key ] ) ) {
						$had_unresolved_predefined = true;
						continue;
					}
					$service_section = $schema_arr[ $service_key ];
					if ( ! is_array( $service_section ) && ! is_object( $service_section ) ) {
						$had_unresolved_predefined = true;
						continue;
					}

					$enabled_for_service = array();
					foreach ( $ids as $id ) {
						$enabled_for_service[ (string) $id ] = true;
					}
					$resolved_for_service = array();

					foreach ( (array) $service_section as $group ) {
						$group_arr   = (array) $group;
						$definitions = $group_arr['definitions'] ?? array();
						if ( ! is_array( $definitions ) && ! is_object( $definitions ) ) {
							continue;
						}
						foreach ( (array) $definitions as $definition ) {
							$def_arr = (array) $definition;
							$def_id  = isset( $def_arr['id'] ) ? (string) $def_arr['id'] : '';
							if ( '' === $def_id || ! isset( $enabled_for_service[ $def_id ] ) ) {
								continue;
							}
							$resolved_for_service[ $def_id ] = true;

							// MVP: only rigid boxes are modeled correctly. Read
							// `type` from the schema definition when present,
							// but older/current predefined schemas often only
							// expose `is_letter`. In that shape, a false
							// `is_letter` value means the definition is a
							// rigid box.
							$def_type = $this->get_predefined_package_type( $def_arr );
							if ( 'box' !== $def_type ) {
								$this->logger->log(
									sprintf(
										'Auto-assign skipped predefined package "%s" under service "%s": type "%s" is not a box (MVP only supports rigid boxes)',
										$def_id,
										$service_key,
										$def_type
									),
									__CLASS__
								);
								continue;
							}

							$had_box_candidate = true;

							$inner_dimensions = isset( $def_arr['inner_dimensions'] ) ? (string) $def_arr['inner_dimensions'] : '';
							$dims             = $this->parse_dimensions( $inner_dimensions );
							if ( null === $dims ) {
								$this->logger->log(
									sprintf(
										'Auto-assign skipped predefined package "%s" under service "%s": malformed inner_dimensions "%s"',
										$def_id,
										$service_key,
										$inner_dimensions
									),
									__CLASS__
								);
								continue;
							}

							$internal_id = sprintf( 'predef:%s:%s', $service_key, $def_id );
							if ( isset( $added_internal[ $internal_id ] ) ) {
								continue;
							}

							$packages[]                     = array(
								'id'         => $internal_id,
								'package_id' => $def_id,
								'service_id' => $service_key,
								'name'       => isset( $def_arr['name'] ) ? (string) $def_arr['name'] : '',
								'length'     => $dims['length'],
								'width'      => $dims['width'],
								'height'     => $dims['height'],
								'box_weight' => isset( $def_arr['box_weight'] ) ? (float) $def_arr['box_weight'] : 0.0,
								'max_weight' => isset( $def_arr['max_weight'] ) ? (float) $def_arr['max_weight'] : 0.0,
							);
							$added_internal[ $internal_id ] = true;
						}
					}

					foreach ( array_keys( $enabled_for_service ) as $enabled_id ) {
						if ( ! isset( $resolved_for_service[ $enabled_id ] ) ) {
							$had_unresolved_predefined = true;
							break;
						}
					}
				}
			}
		}

		return array(
			'packages'                  => $packages,
			'had_box_candidate'         => $had_box_candidate,
			'had_unresolved_predefined' => $had_unresolved_predefined,
		);
	}

	/**
	 * Return true if at least one predefined package id is enabled.
	 *
	 * `get_predefined_packages()` returns a map of service id to id list
	 * (e.g. `[ 'usps' => [], 'fedex' => [] ]`), which is non-empty as a
	 * map even when every inner list is empty. Walk the inner lists so
	 * callers correctly recognize "zero enabled predefined ids".
	 *
	 * @param mixed $enabled_predefined Map of service id to enabled predefined ids, or any non-array value (treated as "nothing enabled").
	 *
	 * @return bool
	 */
	private static function has_enabled_predefined( $enabled_predefined ): bool {
		if ( ! is_array( $enabled_predefined ) ) {
			return false;
		}
		foreach ( $enabled_predefined as $ids ) {
			if ( ! empty( $ids ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Resolve the predefined package type from schema data.
	 *
	 * Older/current predefined package schemas may omit `type` and only carry
	 * `is_letter`. Treat `is_letter: false` as a box so enabled carrier boxes
	 * remain usable with those schemas.
	 *
	 * @param array $definition Predefined package definition from the schema.
	 *
	 * @return string Package type.
	 */
	private function get_predefined_package_type( array $definition ): string {
		if ( isset( $definition['type'] ) && '' !== (string) $definition['type'] ) {
			return (string) $definition['type'];
		}

		if ( array_key_exists( 'is_letter', $definition ) ) {
			return $this->is_schema_truthy( $definition['is_letter'] )
				? 'envelope'
				: 'box';
		}

		return '';
	}

	/**
	 * Interpret bool-like schema values.
	 *
	 * @param mixed $value Schema value.
	 *
	 * @return bool
	 */
	private function is_schema_truthy( $value ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}

		if ( is_numeric( $value ) ) {
			return (float) $value > 0;
		}

		return in_array( strtolower( (string) $value ), array( '1', 'true', 'yes', 'on' ), true );
	}

	/**
	 * Parse an "L x W x H" dimension string into floats.
	 *
	 * Returns null if the string does not match the expected shape so callers
	 * can skip the package rather than feeding 0x0x0 into the packer.
	 *
	 * @param string $dimensions Dimension string, e.g. "10 x 10 x 10".
	 *
	 * @return array{length: float, width: float, height: float}|null
	 */
	private function parse_dimensions( string $dimensions ): ?array {
		// Single optional decimal per token. The looser `[0-9.]+` would have
		// matched "1.2.3" and silently truncated to 1.2 via the float cast.
		if ( ! preg_match( '/^\s*(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*$/i', $dimensions, $m ) ) {
			return null;
		}

		$length = (float) $m[1];
		$width  = (float) $m[2];
		$height = (float) $m[3];

		if ( $length <= 0 || $width <= 0 || $height <= 0 ) {
			return null;
		}

		return array(
			'length' => $length,
			'width'  => $width,
			'height' => $height,
		);
	}

	/**
	 * Return the name of the first product whose length, width, height, or
	 * weight is missing or non-positive. The status constant
	 * STATUS_MISSING_DIMENSIONS keeps its public value for client compatibility,
	 * but the helper is named to reflect that it inspects all four metrics.
	 *
	 * @param array<int, array{product: \WC_Product, quantity: int}> $shippable_items Items.
	 *
	 * @return string|null
	 */
	private function find_item_with_invalid_metrics( array $shippable_items ): ?string {
		foreach ( $shippable_items as $item_data ) {
			$product = $item_data['product'];
			if (
				( (float) $product->get_length() ) <= 0
				|| ( (float) $product->get_width() ) <= 0
				|| ( (float) $product->get_height() ) <= 0
				|| ( (float) $product->get_weight() ) <= 0
			) {
				return (string) $product->get_name();
			}
		}
		return null;
	}
}