<?php
/**
 * WooCommerce order lifecycle event engine.
 *
 * @package Carticy\Conversions\Events
 */

declare(strict_types=1);

namespace Carticy\Conversions\Events;

use Carticy\Conversions\Domain\ConsentState;
use Carticy\Conversions\Domain\ConversionEvent;
use Carticy\Conversions\Domain\ConversionEventType;
use Carticy\Conversions\Domain\ConversionItem;
use Carticy\Conversions\Domain\IdempotencyKey;
use Carticy\Conversions\Domain\Money;
use Carticy\Conversions\Storage\OrderMetaStore;
use Carticy\Conversions\Support\DiagnosticLogger;

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

/**
 * Builds canonical order events from authoritative WooCommerce lifecycle hooks.
 */
final class OrderEventService {

	/**
	 * Order storage.
	 *
	 * @var OrderMetaStore
	 */
	private OrderMetaStore $store;

	/**
	 * Destinations keyed by idempotency destination key.
	 *
	 * @var array<string, EventDestinationInterface>
	 */
	private array $destinations;

	/**
	 * Per-event-type toggles (Settings · Events).
	 *
	 * @var EventToggles
	 */
	private EventToggles $toggles;

	/**
	 * Constructor.
	 *
	 * @param OrderMetaStore                        $store Order storage.
	 * @param array<int, EventDestinationInterface> $destinations Destinations.
	 * @param EventToggles|null                     $toggles Per-event-type toggles.
	 */
	public function __construct( OrderMetaStore $store, array $destinations = array(), ?EventToggles $toggles = null ) {
		$this->store        = $store;
		$this->toggles      = $toggles ?? new EventToggles();
		$this->destinations = $this->normalize_destinations(
			array() !== $destinations ? $destinations : array( new InternalEventDestination() )
		);
	}

	/**
	 * Register WooCommerce lifecycle hooks.
	 */
	public function register_hooks(): void {
		add_action( 'woocommerce_payment_complete', array( $this, 'handle_payment_complete' ), 20, 2 );
		add_action( 'woocommerce_order_status_processing', array( $this, 'handle_paid_status' ), 20, 3 );
		add_action( 'woocommerce_order_status_completed', array( $this, 'handle_paid_status' ), 20, 3 );
		add_action( 'woocommerce_order_refunded', array( $this, 'handle_order_refunded' ), 20, 2 );
		add_action( 'woocommerce_subscription_renewal_payment_complete', array( $this, 'handle_subscription_renewal_payment_complete' ), 20, 2 );
		add_action( 'woocommerce_renewal_order_payment_complete', array( $this, 'handle_renewal_order_payment_complete' ), 20, 1 );

		// WooCommerce Subscriptions copies order meta parent -> subscription ->
		// renewal order. Copied snapshots carry the parent's order id and stale
		// consent/identity, so the engine owns propagation explicitly instead
		// (see ensure_renewal_snapshots()).
		add_filter( 'wc_subscriptions_object_data', array( $this, 'exclude_snapshot_meta_from_subscription_copy' ), 10, 1 );
	}

	/**
	 * Strip plugin snapshot meta from WooCommerce Subscriptions meta copying.
	 *
	 * @param mixed $data Meta data keyed by meta key.
	 * @return mixed
	 */
	public function exclude_snapshot_meta_from_subscription_copy( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}

		foreach ( array_keys( $data ) as $meta_key ) {
			if ( is_string( $meta_key ) && 0 === strpos( $meta_key, '_carticy_conversions_' ) ) {
				unset( $data[ $meta_key ] );
			}
		}

		return $data;
	}

	/**
	 * Handle WooCommerce's authoritative payment-complete signal.
	 *
	 * Args are untyped on purpose: gateways pass this hook's transaction id
	 * through verbatim and a null value must never become a TypeError inside
	 * the payment flow.
	 *
	 * @param mixed $order_id Order ID.
	 * @param mixed $transaction_id Gateway transaction ID (unused).
	 * @return ConversionEvent|null
	 */
	public function handle_payment_complete( $order_id, $transaction_id = '' ): ?ConversionEvent {
		unset( $transaction_id );

		return $this->guarded(
			'woocommerce_payment_complete',
			function () use ( $order_id ): ?ConversionEvent {
				$order = $this->load_order( (int) $order_id );

				return null !== $order ? $this->create_purchase_event( $order, 'woocommerce_payment_complete' ) : null;
			}
		);
	}

	/**
	 * Handle paid-status fallbacks for gateways that do not call payment_complete.
	 *
	 * @param mixed $order_id Order ID.
	 * @param mixed $order Order object.
	 * @param mixed $transition Transition data.
	 * @return ConversionEvent|null
	 */
	public function handle_paid_status( $order_id, $order = null, $transition = null ): ?ConversionEvent {
		unset( $transition );

		return $this->guarded(
			'woocommerce_order_status_paid',
			function () use ( $order_id, $order ): ?ConversionEvent {
				$order = is_object( $order ) ? $order : $this->load_order( (int) $order_id );

				return null !== $order ? $this->create_purchase_event( $order, 'woocommerce_order_status_paid' ) : null;
			}
		);
	}

	/**
	 * Handle WooCommerce refund creation.
	 *
	 * Exception isolation is load-bearing here: wc_create_refund() responds to
	 * an exception from this hook by deleting the refund it just created, so a
	 * storage failure must never escape.
	 *
	 * @param mixed $order_id Order ID.
	 * @param mixed $refund_id Refund ID.
	 * @return ConversionEvent|null
	 */
	public function handle_order_refunded( $order_id, $refund_id ): ?ConversionEvent {
		return $this->guarded(
			'woocommerce_order_refunded',
			function () use ( $order_id, $refund_id ): ?ConversionEvent {
				$order  = $this->load_order( (int) $order_id );
				$refund = $this->load_order( (int) $refund_id );

				if ( null === $order || null === $refund ) {
					return null;
				}

				return $this->create_refund_event( $order, $refund, 'woocommerce_order_refunded' );
			}
		);
	}

	/**
	 * Handle WooCommerce Subscriptions' subscription-level renewal hook.
	 *
	 * @param mixed $subscription Subscription object.
	 * @param mixed $last_order Renewal order object.
	 * @return ConversionEvent|null
	 */
	public function handle_subscription_renewal_payment_complete( $subscription, $last_order = false ): ?ConversionEvent {
		unset( $subscription );

		if ( ! is_object( $last_order ) ) {
			return null;
		}

		return $this->guarded(
			'woocommerce_subscription_renewal_payment_complete',
			function () use ( $last_order ): ?ConversionEvent {
				return $this->create_renewal_event( $last_order, 'woocommerce_subscription_renewal_payment_complete' );
			}
		);
	}

	/**
	 * Handle the renewal-order hook used by some Subscriptions payment paths.
	 *
	 * @param mixed $order_id Renewal order ID.
	 * @return ConversionEvent|null
	 */
	public function handle_renewal_order_payment_complete( $order_id ): ?ConversionEvent {
		return $this->guarded(
			'woocommerce_renewal_order_payment_complete',
			function () use ( $order_id ): ?ConversionEvent {
				$order = $this->load_order( (int) $order_id );

				return null !== $order ? $this->create_renewal_event( $order, 'woocommerce_renewal_order_payment_complete' ) : null;
			}
		);
	}

	/**
	 * Run an event operation without letting exceptions escape into
	 * WooCommerce's payment/refund flow.
	 *
	 * A thrown exception inside payment_complete aborts every later listener,
	 * and inside woocommerce_order_refunded it deletes the merchant's refund.
	 * Tracking failures degrade to a logged error, never a broken store
	 * operation.
	 *
	 * @param string   $hook Source hook for diagnostics.
	 * @param callable $operation Operation returning ?ConversionEvent.
	 * @return ConversionEvent|null
	 */
	private function guarded( string $hook, callable $operation ): ?ConversionEvent {
		try {
			return $operation();
		} catch ( \Throwable $exception ) {
			DiagnosticLogger::error(
				'Order event engine failed; tracking skipped for this hook invocation.',
				array(
					'hook'  => $hook,
					'error' => get_class( $exception ),
				)
			);

			return null;
		}
	}

	/**
	 * Create a purchase event from a paid WooCommerce order.
	 *
	 * @param object $order Order object.
	 * @param string $source Source hook.
	 * @return ConversionEvent|null
	 */
	public function create_purchase_event( object $order, string $source ): ?ConversionEvent {
		if ( ! $this->toggles->enabled( ConversionEventType::PURCHASE ) ) {
			return null;
		}

		if ( ! $this->is_paid_order( $order ) ) {
			return null;
		}

		// WooCommerce Subscriptions fires its renewal trigger from inside
		// woocommerce_payment_complete, so a renewal payment also reaches the
		// generic paid hooks. Counting it as a purchase would double the
		// revenue (purchase + renewal with different transaction ids that no
		// platform can dedup). Renewal orders are owned by the renewal path.
		if ( $this->is_renewal_order( $order ) ) {
			DiagnosticLogger::debug(
				'Skipping purchase event for subscription renewal order.',
				array(
					'order_id' => $this->order_id( $order ),
					'source'   => $source,
				)
			);

			return null;
		}

		$order_id = $this->order_id( $order );
		$value    = $this->order_total( $order, 'wc_order_total' );
		if ( null === $value || $value->amount_float() <= 0.0 ) {
			return null;
		}

		$event = new ConversionEvent(
			ConversionEventType::PURCHASE,
			$order_id,
			(string) $order_id,
			$value,
			$this->order_items( $order, $this->currency( $order ) ),
			$this->store->get_identity( $order ),
			$this->store->get_attribution( $order ),
			$this->store->get_consent( $order ),
			$this->paid_at( $order ),
			$source,
			'',
			$this->order_context( $order )
		);

		return $this->dispatch_event( $order, $event );
	}

	/**
	 * Create a GA4-compatible refund event.
	 *
	 * Refund transaction IDs must point at the original order for GA4 semantics,
	 * while the event ID includes the refund ID so partial refunds do not collide.
	 *
	 * @param object $order Order object.
	 * @param object $refund Refund object.
	 * @param string $source Source hook.
	 * @return ConversionEvent|null
	 */
	public function create_refund_event( object $order, object $refund, string $source ): ?ConversionEvent {
		if ( ! $this->toggles->enabled( ConversionEventType::REFUND ) ) {
			return null;
		}

		$order_id  = $this->order_id( $order );
		$refund_id = $this->order_id( $refund );
		$value     = $this->refund_total( $refund, $this->currency( $order ) );
		if ( null === $value || $value->amount_float() <= 0.0 ) {
			return null;
		}

		$event = new ConversionEvent(
			ConversionEventType::REFUND,
			$order_id,
			(string) $order_id,
			$value,
			$this->refund_items( $refund, $this->currency( $order ) ),
			$this->store->get_identity( $order ),
			$this->store->get_attribution( $order ),
			$this->store->get_consent( $order ),
			gmdate( 'Y-m-d H:i:s' ),
			$source,
			'refund_order_' . $order_id . '_refund_' . $refund_id
		);

		return $this->dispatch_event( $order, $event );
	}

	/**
	 * Create a subscription renewal event from the renewal order.
	 *
	 * @param object $order Renewal order object.
	 * @param string $source Source hook.
	 * @return ConversionEvent|null
	 */
	public function create_renewal_event( object $order, string $source ): ?ConversionEvent {
		// A disabled renewal toggle skips the renewal event only - renewal orders
		// remain excluded from the purchase path regardless (no silent rerouting).
		if ( ! $this->toggles->enabled( ConversionEventType::RENEWAL ) ) {
			return null;
		}

		if ( ! $this->is_paid_order( $order ) ) {
			return null;
		}

		$order_id = $this->order_id( $order );
		$value    = $this->order_total( $order, 'wc_renewal_order_total' );
		if ( null === $value || $value->amount_float() <= 0.0 ) {
			return null;
		}

		$this->ensure_renewal_snapshots( $order );

		$event = new ConversionEvent(
			ConversionEventType::RENEWAL,
			$order_id,
			'renewal_' . $order_id,
			$value,
			$this->order_items( $order, $this->currency( $order ) ),
			$this->store->get_identity( $order ),
			$this->store->get_attribution( $order ),
			$this->store->get_consent( $order ),
			$this->paid_at( $order ),
			$source
		);

		return $this->dispatch_event( $order, $event );
	}

	/**
	 * Dispatch an event to all local destinations with per-destination claims.
	 *
	 * @param object          $order Order object.
	 * @param ConversionEvent $event Event.
	 * @return ConversionEvent|null Event when newly dispatched.
	 */
	private function dispatch_event( object $order, ConversionEvent $event ): ?ConversionEvent {
		$claimed = array();

		foreach ( $this->destinations as $destination ) {
			$key = new IdempotencyKey( $event->order_id(), $event->event_type(), $destination->key(), $this->idempotency_instance( $event ) );
			if ( $this->store->claim_dispatch( $order, $key, 'claimed' ) ) {
				$claimed[] = array( $destination, $key );
			}
		}

		if ( array() === $claimed ) {
			return null;
		}

		$this->store->save_event( $order, $event, true );

		foreach ( $claimed as $claim ) {
			$destination = $claim[0];
			$key         = $claim[1];
			try {
				$details = array( 'event_id' => $event->event_id() );
				if ( $destination instanceof DetailedEventDestinationInterface ) {
					$result  = $destination->send_with_result( $event );
					$status  = $result->status();
					$details = array_merge( $details, $result->details() );
				} else {
					$status = $destination->send( $event ) ? 'sent' : 'failed';
				}
			} catch ( \Throwable $exception ) {
				$status  = 'failed';
				$details = array(
					'event_id'           => $event->event_id(),
					'last_error_code'    => 'destination_exception',
					// Class name only: exception messages can carry payload
					// fragments that must not reach the dispatch log.
					'last_error_message' => get_class( $exception ),
				);
			}
			$this->store->record_dispatch( $order, $key, $status, true, $details );
		}

		return $event;
	}

	/**
	 * Return an idempotency instance only for event types that can occur more than once per order.
	 *
	 * @param ConversionEvent $event Event.
	 * @return string
	 */
	private function idempotency_instance( ConversionEvent $event ): string {
		return ConversionEventType::REFUND === $event->event_type() ? $event->event_id() : '';
	}

	/**
	 * Build the numeric event context from the order.
	 *
	 * @param object $order Order object.
	 * @return array<string, float>
	 */
	private function order_context( object $order ): array {
		$context = array();
		if ( method_exists( $order, 'get_total_tax' ) ) {
			$context['tax'] = (float) $order->get_total_tax();
		}
		if ( method_exists( $order, 'get_shipping_total' ) ) {
			$context['shipping'] = (float) $order->get_shipping_total();
		}

		return $context;
	}

	/**
	 * Check whether an order is a WooCommerce Subscriptions renewal order.
	 *
	 * @param object $order Order object.
	 * @return bool
	 */
	private function is_renewal_order( object $order ): bool {
		return function_exists( 'wcs_order_contains_renewal' ) && (bool) wcs_order_contains_renewal( $order );
	}

	/**
	 * Make sure a renewal order carries its own identity/attribution/consent
	 * snapshots before the renewal event is built.
	 *
	 * Renewal orders are created in the background, so capture never ran for
	 * them, and the WCS meta copy is excluded (stale embedded order ids).
	 * Snapshots are propagated explicitly from the original parent order via
	 * the subscription, with the consent record rebound to the renewal order
	 * while preserving the customer's original decision and audit trail.
	 * Persisting onto the renewal order keeps the queue worker and DSAR
	 * tooling working from the order they actually process.
	 *
	 * @param object $order Renewal order object.
	 */
	private function ensure_renewal_snapshots( object $order ): void {
		$needs_identity    = array() === $this->store->get_identity( $order )->to_array();
		$needs_attribution = array() === array_filter( $this->store->get_attribution( $order )->to_array() );
		$consent           = $this->store->get_consent( $order );
		$needs_consent     = ConsentState::UNSET === $consent->state() && array() === $consent->sources();

		if ( ! $needs_identity && ! $needs_attribution && ! $needs_consent ) {
			return;
		}

		$parent = $this->renewal_source_order( $order );
		if ( null === $parent ) {
			DiagnosticLogger::debug(
				'Renewal order has no resolvable parent for snapshot propagation.',
				array( 'order_id' => $this->order_id( $order ) )
			);

			return;
		}

		if ( $needs_identity ) {
			$identity = $this->store->get_identity( $parent );
			if ( array() !== $identity->to_array() ) {
				$this->store->save_identity( $order, $identity, false );
			}
		}

		if ( $needs_attribution ) {
			$attribution = $this->store->get_attribution( $parent );
			if ( array() !== array_filter( $attribution->to_array() ) ) {
				$this->store->save_attribution( $order, $attribution, false );
			}
		}

		if ( $needs_consent ) {
			$parent_consent = $this->store->get_consent( $parent );
			if ( ConsentState::UNSET !== $parent_consent->state() || array() !== $parent_consent->sources() ) {
				$this->store->save_consent( $order, $parent_consent->for_order( $this->order_id( $order ) ), false );
			}
		}

		if ( method_exists( $order, 'save' ) ) {
			$order->save();
		}
	}

	/**
	 * Resolve the order whose captured snapshots a renewal inherits.
	 *
	 * @param object $order Renewal order object.
	 * @return object|null
	 */
	private function renewal_source_order( object $order ): ?object {
		if ( ! function_exists( 'wcs_get_subscriptions_for_renewal_order' ) ) {
			return null;
		}

		$subscriptions = wcs_get_subscriptions_for_renewal_order( $order );
		foreach ( (array) $subscriptions as $subscription ) {
			if ( ! is_object( $subscription ) ) {
				continue;
			}

			// The original parent order is where checkout capture ran.
			if ( method_exists( $subscription, 'get_parent' ) ) {
				$parent = $subscription->get_parent();
				if ( is_object( $parent ) ) {
					return $parent;
				}
			}

			return $subscription;
		}

		return null;
	}

	/**
	 * Load a WooCommerce order-like object.
	 *
	 * @param int $order_id Order ID.
	 * @return object|null
	 */
	private function load_order( int $order_id ): ?object {
		if ( $order_id <= 0 || ! function_exists( 'wc_get_order' ) ) {
			return null;
		}

		$order = wc_get_order( $order_id );

		return is_object( $order ) ? $order : null;
	}

	/**
	 * Determine if an order is in a paid state.
	 *
	 * @param object $order Order object.
	 * @return bool
	 */
	private function is_paid_order( object $order ): bool {
		if ( method_exists( $order, 'is_paid' ) && true === (bool) $order->is_paid() ) {
			return true;
		}

		if ( method_exists( $order, 'get_status' ) ) {
			return in_array( (string) $order->get_status(), array( 'processing', 'completed' ), true );
		}

		return false;
	}

	/**
	 * Read order ID.
	 *
	 * @param object $order Order object.
	 * @return int
	 */
	private function order_id( object $order ): int {
		return method_exists( $order, 'get_id' ) ? (int) $order->get_id() : 0;
	}

	/**
	 * Get order currency.
	 *
	 * @param object $order Order object.
	 * @return string
	 */
	private function currency( object $order ): string {
		$currency = method_exists( $order, 'get_currency' ) ? (string) $order->get_currency() : '';
		if ( '' === $currency && function_exists( 'get_woocommerce_currency' ) ) {
			$currency = (string) get_woocommerce_currency();
		}

		// No hardcoded fallback: a wrong currency silently corrupts revenue
		// reporting, so an unresolvable currency skips the event loudly.
		if ( '' === $currency ) {
			DiagnosticLogger::error(
				'Order currency could not be resolved; conversion event skipped.',
				array( 'order_id' => $this->order_id( $order ) )
			);
		}

		return $currency;
	}

	/**
	 * Build a Money value from order total.
	 *
	 * @param object $order Order object.
	 * @param string $source Source label.
	 * @return Money|null
	 */
	private function order_total( object $order, string $source ): ?Money {
		if ( ! method_exists( $order, 'get_total' ) ) {
			return null;
		}

		$currency = $this->currency( $order );
		if ( '' === $currency ) {
			return null;
		}

		return new Money( (string) $order->get_total(), $currency, $source );
	}

	/**
	 * Build a positive Money value from refund total.
	 *
	 * @param object $refund Refund object.
	 * @param string $currency Currency.
	 * @return Money|null
	 */
	private function refund_total( object $refund, string $currency ): ?Money {
		if ( '' === $currency ) {
			return null;
		}

		if ( method_exists( $refund, 'get_amount' ) ) {
			return new Money( (string) abs( (float) $refund->get_amount() ), $currency, 'wc_order_refund_amount' );
		}

		if ( method_exists( $refund, 'get_total' ) ) {
			return new Money( (string) abs( (float) $refund->get_total() ), $currency, 'wc_order_refund_total' );
		}

		return null;
	}

	/**
	 * Get the paid timestamp when WooCommerce exposes it.
	 *
	 * @param object $order Order object.
	 * @return string
	 */
	private function paid_at( object $order ): string {
		if ( method_exists( $order, 'get_date_paid' ) ) {
			$date = $order->get_date_paid();
			// Always normalize to UTC. WC_DateTime::date() formats in the
			// site's local timezone, and every downstream consumer (Meta
			// event_time, GA4 session freshness) parses occurred_at as UTC -
			// a local-time string skews Meta timestamps by the UTC offset and
			// silently disables session freshness on non-UTC stores.
			if ( is_object( $date ) && method_exists( $date, 'getTimestamp' ) ) {
				return gmdate( 'Y-m-d H:i:s', (int) $date->getTimestamp() );
			}
		}

		return gmdate( 'Y-m-d H:i:s' );
	}

	/**
	 * Build canonical order items.
	 *
	 * @param object $order Order object.
	 * @param string $currency Currency.
	 * @return array<int, ConversionItem>
	 */
	private function order_items( object $order, string $currency ): array {
		if ( ! method_exists( $order, 'get_items' ) ) {
			return array();
		}

		$items = array();
		foreach ( $order->get_items() as $item ) {
			$conversion_item = $this->conversion_item_from_order_item( $item, $currency, 'wc_order_item_total' );
			if ( null !== $conversion_item ) {
				$items[] = $conversion_item;
			}
		}

		return $items;
	}

	/**
	 * Build canonical refunded items.
	 *
	 * @param object $refund Refund object.
	 * @param string $currency Currency.
	 * @return array<int, ConversionItem>
	 */
	private function refund_items( object $refund, string $currency ): array {
		if ( ! method_exists( $refund, 'get_items' ) ) {
			return array();
		}

		$items = array();
		foreach ( $refund->get_items() as $item ) {
			$conversion_item = $this->conversion_item_from_order_item( $item, $currency, 'wc_order_refund_item_total' );
			if ( null !== $conversion_item ) {
				$items[] = $conversion_item;
			}
		}

		return $items;
	}

	/**
	 * Convert a WooCommerce line item to a canonical item.
	 *
	 * @param object $item Order item.
	 * @param string $currency Currency.
	 * @param string $source Source label.
	 * @return ConversionItem|null
	 */
	private function conversion_item_from_order_item( object $item, string $currency, string $source ): ?ConversionItem {
		$quantity = method_exists( $item, 'get_quantity' ) ? abs( (int) $item->get_quantity() ) : 1;
		if ( $quantity <= 0 ) {
			$quantity = 1;
		}

		$product    = method_exists( $item, 'get_product' ) ? $item->get_product() : null;
		$product_id = method_exists( $item, 'get_product_id' ) ? (int) $item->get_product_id() : 0;
		if ( is_object( $product ) && method_exists( $product, 'get_id' ) ) {
			$product_id = (int) $product->get_id();
		}
		if ( $product_id <= 0 && method_exists( $item, 'get_variation_id' ) ) {
			$product_id = (int) $item->get_variation_id();
		}
		if ( $product_id <= 0 ) {
			return null;
		}

		$total = method_exists( $item, 'get_total' ) ? abs( (float) $item->get_total() ) : 0.0;
		$price = 0.0 < $total ? $total / $quantity : 0.0;

		$name = method_exists( $item, 'get_name' ) ? (string) $item->get_name() : '';
		if ( '' === $name && is_object( $product ) && method_exists( $product, 'get_name' ) ) {
			$name = (string) $product->get_name();
		}

		$sku = is_object( $product ) && method_exists( $product, 'get_sku' ) ? (string) $product->get_sku() : '';

		return new ConversionItem(
			(string) $product_id,
			$name,
			$quantity,
			new Money( (string) $price, $currency, $source ),
			$sku
		);
	}

	/**
	 * Normalize and de-duplicate destinations.
	 *
	 * @param array<int, EventDestinationInterface> $destinations Destinations.
	 * @return array<string, EventDestinationInterface>
	 *
	 * @throws \InvalidArgumentException When a destination does not implement EventDestinationInterface or has an empty key.
	 */
	private function normalize_destinations( array $destinations ): array {
		$normalized = array();
		foreach ( $destinations as $destination ) {
			if ( ! $destination instanceof EventDestinationInterface ) {
				throw new \InvalidArgumentException( 'Order event destinations must implement EventDestinationInterface.' );
			}

			$key = sanitize_key( $destination->key() );
			if ( '' === $key ) {
				throw new \InvalidArgumentException( 'Order event destination keys cannot be empty.' );
			}

			$normalized[ $key ] = $destination;
		}

		return $normalized;
	}
}
