<?php
/**
 * HPOS-safe order meta storage.
 *
 * @package Carticy\Conversions\Storage
 */

declare(strict_types=1);

namespace Carticy\Conversions\Storage;

use Carticy\Conversions\Domain\AttributionData;
use Carticy\Conversions\Domain\ConsentRecord;
use Carticy\Conversions\Domain\ConversionEvent;
use Carticy\Conversions\Domain\IdempotencyKey;
use Carticy\Conversions\Domain\IdentityData;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- This is the data-access layer for the plugin-owned dispatch table (carticy_conversion_dispatches), which has no WordPress API or abstraction. All statements are prepared (only the trusted prefixed table name is interpolated, with line-level PreparedSQL notes), and the dispatch queue is transactional live state that must never be served from the object cache.
/**
 * Stores order-bound snapshots through WooCommerce order CRUD APIs and dispatch
 * claims through the custom unique-key table.
 */
final class OrderMetaStore {

	/**
	 * Save identity to an order.
	 *
	 * @param object       $order WooCommerce order-like object.
	 * @param IdentityData $identity Identity snapshot.
	 * @param bool         $save Whether to call save immediately.
	 */
	public function save_identity( object $order, IdentityData $identity, bool $save = true ): void {
		$this->assert_order_contract( $order );
		$order->update_meta_data( OrderMetaKeys::IDENTITY, $identity->to_array() );
		$this->maybe_save( $order, $save );
	}

	/**
	 * Get identity from an order.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @return IdentityData
	 */
	public function get_identity( object $order ): IdentityData {
		$this->assert_order_contract( $order );

		return IdentityData::from_array( $this->read_array_meta( $order, OrderMetaKeys::IDENTITY ) );
	}

	/**
	 * Save attribution to an order.
	 *
	 * @param object          $order WooCommerce order-like object.
	 * @param AttributionData $attribution Attribution snapshot.
	 * @param bool            $save Whether to call save immediately.
	 */
	public function save_attribution( object $order, AttributionData $attribution, bool $save = true ): void {
		$this->assert_order_contract( $order );
		$order->update_meta_data( OrderMetaKeys::ATTRIBUTION, $attribution->to_array() );
		$this->maybe_save( $order, $save );
	}

	/**
	 * Get attribution from an order.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @return AttributionData
	 */
	public function get_attribution( object $order ): AttributionData {
		$this->assert_order_contract( $order );

		return AttributionData::from_array( $this->read_array_meta( $order, OrderMetaKeys::ATTRIBUTION ) );
	}

	/**
	 * Save consent to an order.
	 *
	 * @param object        $order WooCommerce order-like object.
	 * @param ConsentRecord $consent Consent snapshot.
	 * @param bool          $save Whether to call save immediately.
	 */
	public function save_consent( object $order, ConsentRecord $consent, bool $save = true ): void {
		$this->assert_order_contract( $order );
		$this->assert_record_order_id( $order, $consent->order_id() );
		$order->update_meta_data( OrderMetaKeys::CONSENT, $consent->to_array() );
		$this->maybe_save( $order, $save );
	}

	/**
	 * Get consent from an order.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @return ConsentRecord
	 */
	public function get_consent( object $order ): ConsentRecord {
		$this->assert_order_contract( $order );

		$order_id = $this->order_id_from_order( $order );
		$data     = $this->read_array_meta( $order, OrderMetaKeys::CONSENT );
		if ( array() === $data ) {
			return ConsentRecord::unset_for_order( $order_id );
		}

		// A snapshot copied between orders (e.g. WooCommerce Subscriptions meta
		// copy) carries the source order's id; rebind it to the order being read
		// so consumers never receive a record pointing at a different order.
		return ConsentRecord::from_array( $data, $order_id )->for_order( $order_id );
	}

	/**
	 * Save a canonical event snapshot.
	 *
	 * @param object          $order WooCommerce order-like object.
	 * @param ConversionEvent $event Event snapshot.
	 * @param bool            $save Whether to call save immediately.
	 */
	public function save_event( object $order, ConversionEvent $event, bool $save = true ): void {
		$this->assert_order_contract( $order );
		$this->assert_record_order_id( $order, $event->order_id() );

		$events                       = $this->read_array_meta( $order, OrderMetaKeys::EVENTS );
		$events[ $event->event_id() ] = $event->to_array();

		$order->update_meta_data( OrderMetaKeys::EVENTS, $events );
		$this->maybe_save( $order, $save );
	}

	/**
	 * Get one event snapshot.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @param string $event_id Event ID.
	 * @return ConversionEvent|null
	 */
	public function get_event( object $order, string $event_id ): ?ConversionEvent {
		$this->assert_order_contract( $order );

		$events = $this->read_array_meta( $order, OrderMetaKeys::EVENTS );
		if ( ! isset( $events[ $event_id ] ) || ! is_array( $events[ $event_id ] ) ) {
			return null;
		}

		// A corrupted or legacy snapshot must degrade to "no event", never
		// fatal a read path (admin screens, hydration, the queue worker).
		try {
			return ConversionEvent::from_array( $events[ $event_id ] );
		} catch ( \Throwable $exception ) {
			\Carticy\Conversions\Support\DiagnosticLogger::error(
				'Skipping corrupt stored conversion event.',
				array(
					'order_id' => $this->order_id_from_order( $order ),
					'event_id' => $event_id,
					'error'    => get_class( $exception ),
				)
			);

			return null;
		}
	}

	/**
	 * Get all event snapshots.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @return array<string, ConversionEvent>
	 */
	public function get_events( object $order ): array {
		$this->assert_order_contract( $order );

		$events = array();
		foreach ( $this->read_array_meta( $order, OrderMetaKeys::EVENTS ) as $event_id => $event ) {
			if ( ! is_array( $event ) ) {
				continue;
			}

			try {
				$events[ (string) $event_id ] = ConversionEvent::from_array( $event );
			} catch ( \Throwable $exception ) {
				\Carticy\Conversions\Support\DiagnosticLogger::error(
					'Skipping corrupt stored conversion event.',
					array(
						'order_id' => $this->order_id_from_order( $order ),
						'event_id' => (string) $event_id,
						'error'    => get_class( $exception ),
					)
				);
			}
		}

		return $events;
	}

	/**
	 * Atomically claim a destination dispatch key before sending.
	 *
	 * @param object               $order WooCommerce order-like object.
	 * @param IdempotencyKey       $key Idempotency key.
	 * @param string               $status Initial dispatch status.
	 * @param array<string, mixed> $details Redacted dispatch details.
	 * @return bool True when this caller created the claim.
	 *
	 * @throws \RuntimeException When no atomic claim backend (dispatch table or order contract) is available.
	 */
	public function claim_dispatch( object $order, IdempotencyKey $key, string $status = 'pending', array $details = array() ): bool {
		$this->assert_order_contract( $order );
		$this->assert_record_order_id( $order, (int) $key->to_array()['order_id'] );

		if ( $this->dispatch_table_available() ) {
			return $this->insert_dispatch_claim( $key, sanitize_key( $status ), $details );
		}

		if ( method_exists( $order, 'claim_dispatch_record' ) ) {
			return (bool) $order->claim_dispatch_record( $key->to_string(), $this->dispatch_record_data( $key, sanitize_key( $status ), $details ) );
		}

		throw new \RuntimeException( 'Atomic dispatch claims require the Carticy Conversions dispatch table.' );
	}

	/**
	 * Record a destination dispatch attempt using the canonical idempotency key.
	 *
	 * @param object               $order WooCommerce order-like object.
	 * @param IdempotencyKey       $key Idempotency key.
	 * @param string               $status Dispatch status.
	 * @param bool                 $save Whether to call save immediately.
	 * @param array<string, mixed> $details Redacted dispatch details.
	 *
	 * @throws \RuntimeException When no dispatch record backend (dispatch table or order contract) is available.
	 */
	public function record_dispatch( object $order, IdempotencyKey $key, string $status = 'pending', bool $save = true, array $details = array() ): void {
		$this->assert_order_contract( $order );
		$this->assert_record_order_id( $order, (int) $key->to_array()['order_id'] );

		$status = sanitize_key( $status );

		if ( $this->dispatch_table_available() ) {
			$this->upsert_dispatch_record( $key, $status, $details );
			return;
		}

		if ( method_exists( $order, 'record_dispatch_record' ) ) {
			$order->record_dispatch_record( $key->to_string(), $this->dispatch_record_data( $key, $status, $details ) );
			$this->maybe_save( $order, $save );
			return;
		}

		throw new \RuntimeException( 'Dispatch records require the Carticy Conversions dispatch table.' );
	}

	/**
	 * Record a dispatch update when only the serialized idempotency key is available.
	 *
	 * @param string               $idempotency_key Serialized idempotency key.
	 * @param string               $status Dispatch status.
	 * @param array<string, mixed> $details Redacted dispatch details.
	 *
	 * @throws \RuntimeException When the dispatch table is unavailable.
	 */
	public function record_dispatch_by_idempotency_key( string $idempotency_key, string $status, array $details = array() ): void {
		$key = IdempotencyKey::from_string( $idempotency_key );

		if ( $this->dispatch_table_available() ) {
			$this->upsert_dispatch_record( $key, sanitize_key( $status ), $details );
			return;
		}

		throw new \RuntimeException( 'Dispatch table updates by idempotency key require the Carticy Conversions dispatch table.' );
	}

	/**
	 * Read due queue rows for provider workers.
	 *
	 * @param array<int, string> $destinations Destination keys.
	 * @param int                $limit Max rows.
	 * @return array<int, array<string, mixed>>
	 */
	public function get_due_dispatches( array $destinations, int $limit = 20 ): array {
		global $wpdb;

		if ( ! $this->dispatch_table_available() ) {
			return array();
		}

		$destination_keys = array_values(
			array_filter(
				array_map( 'sanitize_key', $destinations ),
				static function ( string $destination ): bool {
					return '' !== $destination;
				}
			)
		);

		if ( array() === $destination_keys ) {
			return array();
		}

		$placeholders = implode( ',', array_fill( 0, count( $destination_keys ), '%s' ) );
		$now          = gmdate( 'Y-m-d H:i:s' );
		$prepare_args = array_merge( $destination_keys, array( $now, max( 1, min( 100, $limit ) ) ) );
		// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic IN placeholders are paired with variadic prepared values.
		$sql = $wpdb->prepare(
			'SELECT * FROM ' . SchemaManager::dispatch_table_name() . " WHERE destination IN ({$placeholders}) AND status IN ('queued','retry_scheduled') AND (next_attempt_at IS NULL OR next_attempt_at <= %s) ORDER BY updated_at ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name is plugin-owned; IN placeholders are generated from fixed %s tokens and values are prepared.
			...$prepare_args
		);

		$rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; only the trusted table name is interpolated.

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Read the most recently updated dispatch rows for the admin diagnostics view.
	 *
	 * Read-only and status-agnostic: every row is returned exactly as stored so
	 * the diagnostics layer renders the persisted status (recoverable blocked
	 * states vs the single terminal failure) instead of recomputing it. Returns
	 * an empty list whenever the dispatch table is unavailable (e.g. the free
	 * build before any server send) so callers degrade gracefully.
	 *
	 * @param int $limit Max rows.
	 * @return array<int, array<string, mixed>>
	 */
	public function get_recent_dispatches( int $limit = 50 ): array {
		global $wpdb;

		if ( ! $this->dispatch_table_available() ) {
			return array();
		}

		$sql  = $wpdb->prepare(
			'SELECT * FROM ' . SchemaManager::dispatch_table_name() . ' ORDER BY updated_at DESC, id DESC LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; the limit is prepared.
			max( 1, min( 200, $limit ) )
		);
		$rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; only the trusted table name is interpolated.

		return is_array( $rows ) ? $rows : array();
	}

	/**
	 * Atomically claim one due queue row before sending.
	 *
	 * Overlapping cron workers can both read the same due rows; only the
	 * worker whose conditional UPDATE flips the status to "processing" may
	 * send, so a row is never sent twice concurrently.
	 *
	 * @param int $row_id Queue row ID.
	 * @return bool True when this caller claimed the row.
	 */
	public function claim_due_dispatch_row( int $row_id ): bool {
		/**
		 * WordPress database abstraction.
		 *
		 * @var \wpdb $wpdb
		 */
		global $wpdb;

		if ( $row_id <= 0 || ! $this->dispatch_table_available() ) {
			return false;
		}

		// Test doubles without query() run single-process; the claim is
		// trivially this caller's.
		if ( ! method_exists( $wpdb, 'query' ) ) {
			return true;
		}

		$updated = $wpdb->query(
			$wpdb->prepare(
				'UPDATE ' . SchemaManager::dispatch_table_name() // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; values are prepared.
					. " SET status = 'processing', updated_at = %s WHERE id = %d AND status IN ('queued','retry_scheduled')",
				gmdate( 'Y-m-d H:i:s' ),
				$row_id
			)
		);

		return 1 === (int) $updated;
	}

	/**
	 * Recover rows stuck mid-flight by a crash between claim and record.
	 *
	 * "claimed" rows belong to the synchronous dispatch path and "processing"
	 * rows to the queue worker; either left older than the threshold means the
	 * process died before recording an outcome. Requeueing lets the worker
	 * re-run the full preflight (consent, config, identity) before any send.
	 *
	 * @param int $stale_minutes Age threshold in minutes.
	 * @return int Number of recovered rows.
	 */
	public function requeue_stale_dispatches( int $stale_minutes = 30 ): int {
		/**
		 * WordPress database abstraction.
		 *
		 * @var \wpdb $wpdb
		 */
		global $wpdb;

		if ( ! $this->dispatch_table_available() || ! method_exists( $wpdb, 'query' ) ) {
			return 0;
		}

		$cutoff  = gmdate( 'Y-m-d H:i:s', time() - ( max( 5, $stale_minutes ) * 60 ) );
		$updated = $wpdb->query(
			$wpdb->prepare(
				'UPDATE ' . SchemaManager::dispatch_table_name() // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; values are prepared.
					. " SET status = 'queued', next_attempt_at = NULL, updated_at = %s WHERE status IN ('claimed','processing') AND updated_at < %s",
				gmdate( 'Y-m-d H:i:s' ),
				$cutoff
			)
		);

		return is_numeric( $updated ) ? (int) $updated : 0;
	}

	/**
	 * Requeue blocked rows so a later-granted consent or later-added
	 * credentials eventually send.
	 *
	 * Blocked states are re-examinations, not terminal verdicts: the worker's
	 * preflight re-reads the stored order consent and current provider config
	 * on every attempt, so requeueing is safe - a still-blocked row simply
	 * returns to its blocked state. Only rows untouched for the threshold are
	 * requeued, bounding how often blocked work is re-checked.
	 *
	 * @param array<int, string> $destinations Destination keys.
	 * @param int                $min_age_minutes Re-check age threshold in minutes.
	 * @param int                $limit Max rows per sweep.
	 * @return int Number of requeued rows.
	 */
	public function requeue_blocked_dispatches( array $destinations, int $min_age_minutes = 60, int $limit = 50 ): int {
		/**
		 * WordPress database abstraction.
		 *
		 * @var \wpdb $wpdb
		 */
		global $wpdb;

		if ( ! $this->dispatch_table_available() || ! method_exists( $wpdb, 'query' ) ) {
			return 0;
		}

		$destination_keys = array_values(
			array_filter(
				array_map( 'sanitize_key', $destinations ),
				static function ( string $destination ): bool {
					return '' !== $destination;
				}
			)
		);

		if ( array() === $destination_keys ) {
			return 0;
		}

		$placeholders = implode( ',', array_fill( 0, count( $destination_keys ), '%s' ) );
		$cutoff       = gmdate( 'Y-m-d H:i:s', time() - ( max( 5, $min_age_minutes ) * 60 ) );
		$prepare_args = array_merge( array( gmdate( 'Y-m-d H:i:s' ) ), $destination_keys, array( $cutoff, max( 1, min( 200, $limit ) ) ) );
		$updated      = $wpdb->query(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic IN placeholders are paired with variadic prepared values.
			$wpdb->prepare(
				'UPDATE ' . SchemaManager::dispatch_table_name() // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; values are prepared.
					. " SET status = 'queued', next_attempt_at = NULL, updated_at = %s"
					. " WHERE destination IN ({$placeholders}) AND status IN ('blocked_consent','blocked_missing_config') AND updated_at < %s" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- IN placeholders are generated from fixed %s tokens and values are prepared.
					. ' ORDER BY updated_at ASC LIMIT %d',
				...$prepare_args
			)
		);

		return is_numeric( $updated ) ? (int) $updated : 0;
	}

	/**
	 * Delete dispatch rows for one order (DSAR erasure / uninstall).
	 *
	 * @param int $order_id Order ID.
	 * @return int Number of deleted rows.
	 */
	public function delete_dispatches_for_order( int $order_id ): int {
		/**
		 * WordPress database abstraction.
		 *
		 * @var \wpdb $wpdb
		 */
		global $wpdb;

		if ( $order_id <= 0 || ! $this->dispatch_table_available() || ! method_exists( $wpdb, 'query' ) ) {
			return 0;
		}

		$deleted = $wpdb->query(
			$wpdb->prepare(
				'DELETE FROM ' . SchemaManager::dispatch_table_name() . ' WHERE order_id = %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; values are prepared.
				$order_id
			)
		);

		return is_numeric( $deleted ) ? (int) $deleted : 0;
	}

	/**
	 * Purge dispatch rows older than the retention cutoff.
	 *
	 * Only settled rows are purged; queued/processing work is never deleted
	 * out from under the worker.
	 *
	 * @param string $cutoff GMT datetime cutoff.
	 * @return int Number of deleted rows.
	 */
	public function purge_dispatches_older_than( string $cutoff ): int {
		/**
		 * WordPress database abstraction.
		 *
		 * @var \wpdb $wpdb
		 */
		global $wpdb;

		$cutoff = trim( $cutoff );
		if ( '' === $cutoff || ! $this->dispatch_table_available() || ! method_exists( $wpdb, 'query' ) ) {
			return 0;
		}

		$deleted = $wpdb->query(
			$wpdb->prepare(
				'DELETE FROM ' . SchemaManager::dispatch_table_name() // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; values are prepared.
					. " WHERE updated_at < %s AND status NOT IN ('queued','retry_scheduled','processing','claimed')",
				substr( $cutoff, 0, 19 )
			)
		);

		return is_numeric( $deleted ) ? (int) $deleted : 0;
	}

	/**
	 * Check whether a dispatch key was already recorded.
	 *
	 * @param object         $order WooCommerce order-like object.
	 * @param IdempotencyKey $key Idempotency key.
	 * @return bool
	 *
	 * @throws \RuntimeException When no dispatch record backend (dispatch table or order contract) is available.
	 */
	public function has_dispatch_record( object $order, IdempotencyKey $key ): bool {
		$this->assert_order_contract( $order );
		$this->assert_record_order_id( $order, (int) $key->to_array()['order_id'] );

		if ( $this->dispatch_table_available() ) {
			return $this->dispatch_claim_exists( $key );
		}

		if ( method_exists( $order, 'has_dispatch_record' ) ) {
			return (bool) $order->has_dispatch_record( $key->to_string() );
		}

		throw new \RuntimeException( 'Dispatch records require the Carticy Conversions dispatch table.' );
	}

	/**
	 * Read an array meta value, accepting legacy JSON only as a defensive fallback.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @param string $key Meta key.
	 * @return array<string, mixed>
	 */
	private function read_array_meta( object $order, string $key ): array {
		$value = $order->get_meta( $key, true );

		if ( is_array( $value ) ) {
			return $value;
		}

		if ( is_string( $value ) && '' !== trim( $value ) ) {
			$decoded = json_decode( $value, true );
			return is_array( $decoded ) ? $decoded : array();
		}

		return array();
	}

	/**
	 * Assert the minimal WooCommerce order CRUD contract.
	 *
	 * @param object $order Order-like object.
	 *
	 * @throws \InvalidArgumentException When the object lacks a WooCommerce order meta CRUD method.
	 */
	private function assert_order_contract( object $order ): void {
		foreach ( array( 'get_id', 'get_meta', 'update_meta_data' ) as $method ) {
			if ( ! method_exists( $order, $method ) ) {
				throw new \InvalidArgumentException( 'Order object must support WooCommerce order meta CRUD methods.' );
			}
		}
	}

	/**
	 * Read an order ID from the WooCommerce order contract.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @return int
	 *
	 * @throws \InvalidArgumentException When the order does not expose a positive ID.
	 */
	private function order_id_from_order( object $order ): int {
		$order_id = (int) $order->get_id();
		if ( $order_id <= 0 ) {
			throw new \InvalidArgumentException( 'Order object must expose a positive ID.' );
		}

		return $order_id;
	}

	/**
	 * Ensure order-bound records cannot be stored under another order.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @param int    $record_order_id Record order ID.
	 *
	 * @throws \InvalidArgumentException When the record order ID does not match the target order.
	 */
	private function assert_record_order_id( object $order, int $record_order_id ): void {
		if ( $this->order_id_from_order( $order ) !== $record_order_id ) {
			throw new \InvalidArgumentException( 'Order-bound conversion record does not match the target order.' );
		}
	}

	/**
	 * Save the order when requested.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @param bool   $save Whether to call save immediately.
	 */
	private function maybe_save( object $order, bool $save ): void {
		if ( $save && method_exists( $order, 'save' ) ) {
			$order->save();
		}
	}

	/**
	 * Check whether the WordPress DB API is available for dispatch claims.
	 *
	 * @return bool
	 */
	private function dispatch_table_available(): bool {
		global $wpdb;

		return isset( $wpdb )
			&& is_object( $wpdb )
			&& method_exists( $wpdb, 'insert' )
			&& method_exists( $wpdb, 'update' )
			&& method_exists( $wpdb, 'get_var' )
			&& method_exists( $wpdb, 'get_results' )
			&& method_exists( $wpdb, 'prepare' );
	}

	/**
	 * Insert the unique dispatch claim.
	 *
	 * @param IdempotencyKey       $key Idempotency key.
	 * @param string               $status Initial status.
	 * @param array<string, mixed> $details Redacted dispatch details.
	 * @return bool True when the claim was inserted.
	 *
	 * @throws \RuntimeException When the insert fails for a reason other than losing the claim race.
	 */
	private function insert_dispatch_claim( IdempotencyKey $key, string $status, array $details = array() ): bool {
		global $wpdb;

		$data                       = $this->dispatch_record_data( $key, $status, $details );
		$previous_error_suppression = method_exists( $wpdb, 'suppress_errors' ) ? $wpdb->suppress_errors( true ) : null;
		$inserted                   = $wpdb->insert(
			SchemaManager::dispatch_table_name(),
			array(
				'order_id'           => $data['order_id'],
				'event_type'         => $data['event_type'],
				'destination'        => $data['destination'],
				'idempotency_key'    => $data['key'],
				'event_id'           => $data['event_id'],
				'status'             => $data['status'],
				'attempts'           => 0,
				'next_attempt_at'    => $data['next_attempt_at'],
				'last_attempt_at'    => $data['last_attempt_at'],
				'last_http_status'   => $data['last_http_status'],
				'last_error_code'    => $data['last_error_code'],
				'last_error_message' => $data['last_error_message'],
				'response_summary'   => $data['response_summary'],
				'payload_hash'       => $data['payload_hash'],
				'created_at'         => $data['created_at'],
				'updated_at'         => $data['updated_at'],
			),
			array( '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
		);
		if ( null !== $previous_error_suppression ) {
			$wpdb->suppress_errors( $previous_error_suppression );
		}

		if ( false !== $inserted ) {
			return true;
		}

		if ( $this->dispatch_claim_exists( $key ) ) {
			return false;
		}

		$error = isset( $wpdb->last_error ) ? (string) $wpdb->last_error : '';
		throw new \RuntimeException( 'Unable to create dispatch claim. ' . esc_html( $error ) );
	}

	/**
	 * Upsert a dispatch record after a claim has been created.
	 *
	 * @param IdempotencyKey       $key Idempotency key.
	 * @param string               $status Dispatch status.
	 * @param array<string, mixed> $details Redacted dispatch details.
	 */
	private function upsert_dispatch_record( IdempotencyKey $key, string $status, array $details = array() ): void {
		global $wpdb;

		if ( ! $this->dispatch_claim_exists( $key ) ) {
			// Losing the insert race means another process created the claim
			// between the existence check and the insert; fall through and
			// apply this status update to that row instead of dropping it.
			if ( $this->insert_dispatch_claim( $key, $status, $details ) ) {
				return;
			}
		}

		$data    = $this->dispatch_record_data( $key, $status, $details );
		$update  = array(
			'status'     => $data['status'],
			'updated_at' => $data['updated_at'],
		);
		$formats = array( '%s', '%s' );

		foreach ( $this->dispatch_detail_keys() as $column => $format ) {
			if ( array_key_exists( $column, $details ) ) {
				$update[ $column ] = $data[ $column ];
				$formats[]         = $format;
			}
		}

		$wpdb->update(
			SchemaManager::dispatch_table_name(),
			$update,
			array(
				'idempotency_key' => $data['key'],
			),
			$formats,
			array( '%s' )
		);
	}

	/**
	 * Check for an existing dispatch claim.
	 *
	 * @param IdempotencyKey $key Idempotency key.
	 * @return bool
	 */
	private function dispatch_claim_exists( IdempotencyKey $key ): bool {
		global $wpdb;

		$data = $key->to_array();
		$sql  = $wpdb->prepare(
			'SELECT id FROM ' . SchemaManager::dispatch_table_name() . ' WHERE idempotency_key = %s LIMIT 1', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name from the trusted plugin prefix; values are prepared.
			$data['key']
		);

		return null !== $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; only the trusted table name is interpolated.
	}

	/**
	 * Build a dispatch record array.
	 *
	 * @param IdempotencyKey       $key Idempotency key.
	 * @param string               $status Dispatch status.
	 * @param array<string, mixed> $details Redacted dispatch details.
	 * @return array<string, mixed>
	 */
	private function dispatch_record_data( IdempotencyKey $key, string $status, array $details = array() ): array {
		$key_data = $key->to_array();
		$event_id = (string) ( $details['event_id'] ?? $key_data['event_id'] ?? '' );

		return array_merge(
			$key_data,
			array(
				'event_id'           => sanitize_key( $event_id ),
				'status'             => $status,
				'next_attempt_at'    => $this->normalize_datetime_detail( $details['next_attempt_at'] ?? null ),
				'last_attempt_at'    => $this->normalize_datetime_detail( $details['last_attempt_at'] ?? null ),
				'last_http_status'   => isset( $details['last_http_status'] ) ? abs( (int) $details['last_http_status'] ) : null,
				'attempts'           => isset( $details['attempts'] ) ? abs( (int) $details['attempts'] ) : 0,
				'last_error_code'    => $this->normalize_text_detail( $details['last_error_code'] ?? '', 64 ),
				'last_error_message' => $this->normalize_text_detail( $details['last_error_message'] ?? '', 500 ),
				'response_summary'   => $this->normalize_text_detail( $details['response_summary'] ?? '', 500 ),
				'payload_hash'       => $this->normalize_text_detail( $details['payload_hash'] ?? '', 64 ),
				'created_at'         => gmdate( 'Y-m-d H:i:s' ),
				'updated_at'         => gmdate( 'Y-m-d H:i:s' ),
			)
		);
	}

	/**
	 * Queue detail columns and formats.
	 *
	 * @return array<string, string>
	 */
	private function dispatch_detail_keys(): array {
		return array(
			'event_id'           => '%s',
			'next_attempt_at'    => '%s',
			'last_attempt_at'    => '%s',
			'last_http_status'   => '%d',
			'attempts'           => '%d',
			'last_error_code'    => '%s',
			'last_error_message' => '%s',
			'response_summary'   => '%s',
			'payload_hash'       => '%s',
		);
	}

	/**
	 * Normalize a datetime diagnostic value.
	 *
	 * @param mixed $value Raw value.
	 * @return string|null
	 */
	private function normalize_datetime_detail( $value ): ?string {
		$value = is_scalar( $value ) ? trim( (string) $value ) : '';

		return '' !== $value ? substr( $value, 0, 19 ) : null;
	}

	/**
	 * Normalize a text diagnostic value.
	 *
	 * @param mixed $value Raw value.
	 * @param int   $length Max length.
	 * @return string
	 */
	private function normalize_text_detail( $value, int $length ): string {
		if ( ! is_scalar( $value ) ) {
			$value = wp_json_encode( $value );
		}

		return substr( trim( (string) $value ), 0, $length );
	}
}
