<?php
/**
 * In-memory embed-item repository for tests.
 *
 * Reference {@see EmbedItemRepository} implementation that keeps
 * every junction row in a plain PHP array on the instance, with no
 * dependency on `$wpdb` or the WordPress options/plugin lifecycle.
 * This is the canonical test double used by pure-unit tests under
 * `tests/Unit/Infrastructure/Persistence/` and by the Phase 2
 * KI-002 cache-invalidator tests that exercise the per-product
 * reverse-index lookup; integration tests that need real WordPress
 * / MySQL semantics should exercise the Wpdb-backed implementation
 * against a live database instead.
 *
 * Semantic parity with the Wpdb-backed implementation:
 *
 *   - {@see self::findEmbedIdsByProductId()} returns ascending
 *     embed ids and treats "no matching items" as a normal empty
 *     result, matching the contract.
 *
 *   - {@see self::replaceEmbedItems()} performs a delete-then-
 *     insert: every existing row for `$embedId` is removed before
 *     the supplied `$productIds` are inserted with `sort_order`
 *     taken from their position in the list (0-based). An empty
 *     `$productIds` list clears the embed's items.
 *
 *   - Duplicate product ids inside a single
 *     {@see self::replaceEmbedItems()} call raise
 *     {@see \InvalidArgumentException} BEFORE any state mutation,
 *     so the caller observes the validation failure with the
 *     existing rows intact.
 *
 *   - The §7.3 unique constraint on `(embed_id, product_id)` is
 *     naturally upheld by the delete-then-insert flow plus the
 *     pre-insert duplicate check; this double does NOT need a
 *     separate index structure to enforce it.
 *
 *   - {@see self::deleteByEmbedId()} is idempotent and returns
 *     the count of removed rows.
 *
 *   - {@see self::count()} reports the total junction-row count
 *     across every embed.
 *
 * The store deliberately holds the rows as plain associative
 * arrays (not a dedicated record type) because the junction row
 * carries no domain-meaningful state beyond the
 * (embed_id, product_id, sort_order) triple, matching the
 * decision documented on the {@see EmbedItemRepository} contract.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Array-backed {@see EmbedItemRepository} implementation for unit tests.
 *
 * See the file-level docblock for the semantic parity guarantees this
 * double maintains with the Wpdb-backed implementation.
 */
final class InMemoryEmbedItemRepository implements EmbedItemRepository {

	/**
	 * Stored junction rows.
	 *
	 * Each entry is a `{embed_id: int, product_id: int, sort_order: int}`
	 * triple. Ordering inside the array is insertion order; the
	 * read methods explicitly sort their projections so callers
	 * never depend on internal ordering.
	 *
	 * @var list<array{embed_id: int, product_id: int, sort_order: int}>
	 */
	private array $rows = array();

	/**
	 * Construct the repository, optionally seeded with a set of
	 * already-persisted rows.
	 *
	 * Seeding is primarily useful for tests that need to exercise
	 * "existing environment" code paths without driving a sequence
	 * of {@see self::replaceEmbedItems()} calls first. Each seed
	 * entry MUST be a `{embed_id, product_id, sort_order}` triple
	 * with positive `embed_id` / `product_id` and non-negative
	 * `sort_order`. Duplicate `(embed_id, product_id)` pairs raise
	 * {@see InvalidArgumentException} so a malformed seed fails
	 * loudly rather than silently violating the §7.3 unique
	 * constraint.
	 *
	 * @param list<array{embed_id: int, product_id: int, sort_order?: int}> $seed Rows to preload.
	 *
	 * @throws InvalidArgumentException When a seed entry is malformed
	 *                                  or when two seeds share the
	 *                                  same `(embed_id, product_id)`
	 *                                  pair.
	 */
	public function __construct( array $seed = array() ) {
		$seenPairs = array();
		foreach ( $seed as $index => $entry ) {
			if ( ! is_array( $entry ) ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository seed[%d] must be an array.',
					(int) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! array_key_exists( 'embed_id', $entry ) || ! array_key_exists( 'product_id', $entry ) ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository seed[%d] missing required keys "embed_id" / "product_id".',
					(int) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$embedId   = (int) $entry['embed_id'];
			$productId = (int) $entry['product_id'];
			$sortOrder = array_key_exists( 'sort_order', $entry ) ? (int) $entry['sort_order'] : 0;

			if ( $embedId <= 0 ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository seed[%d] requires positive embed_id; got %d.',
					(int) $index,
					$embedId
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $productId <= 0 ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository seed[%d] requires positive product_id; got %d.',
					(int) $index,
					$productId
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $sortOrder < 0 ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository seed[%d] requires non-negative sort_order; got %d.',
					(int) $index,
					$sortOrder
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$pairKey = $embedId . ':' . $productId;
			if ( array_key_exists( $pairKey, $seenPairs ) ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository seed contains duplicate (embed_id=%d, product_id=%d) pair.',
					$embedId,
					$productId
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$seenPairs[ $pairKey ] = true;

			$this->rows[] = array(
				'embed_id'   => $embedId,
				'product_id' => $productId,
				'sort_order' => $sortOrder,
			);
		}
	}

	/**
	 * Find every embed id that references the supplied product.
	 *
	 * Returns ascending embed ids per the contract. An empty list
	 * is the normal "no embed references this product" outcome.
	 *
	 * @param int $productId Local `clb_products.id` to look up.
	 *
	 * @return list<int>
	 *
	 * @throws InvalidArgumentException When `$productId` is non-positive.
	 */
	public function findEmbedIdsByProductId( int $productId ): array {
		if ( $productId <= 0 ) {
			$message = sprintf(
				'InMemoryEmbedItemRepository::findEmbedIdsByProductId() requires a positive productId, got %d.',
				$productId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$matches = array();
		foreach ( $this->rows as $row ) {
			if ( $row['product_id'] === $productId ) {
				$matches[ $row['embed_id'] ] = true;
			}
		}

		$ids = array_keys( $matches );
		sort( $ids, SORT_NUMERIC );

		/** @var list<int> $ids */
		return $ids;
	}

	/**
	 * Replace the items associated with `$embedId` with the
	 * supplied ordered product-id list.
	 *
	 * Validates `$embedId > 0` and that every entry in
	 * `$productIds` is a positive integer with no duplicates.
	 * Then removes every existing row for `$embedId` and inserts
	 * the supplied rows with `sort_order` taken from their
	 * position in the list (0-based).
	 *
	 * @param int       $embedId    Positive `clb_embeds.id`.
	 * @param list<int> $productIds Ordered product ids.
	 *
	 * @return int Number of inserted rows.
	 *
	 * @throws InvalidArgumentException When validation fails.
	 */
	public function replaceEmbedItems( int $embedId, array $productIds ): int {
		if ( $embedId <= 0 ) {
			$message = sprintf(
				'InMemoryEmbedItemRepository::replaceEmbedItems() requires a positive embedId, got %d.',
				$embedId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalized = array();
		$seen       = array();
		foreach ( $productIds as $position => $productId ) {
			if ( ! is_int( $productId ) || $productId <= 0 ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository::replaceEmbedItems() requires positive product ids; productIds[%d] invalid.',
					(int) $position
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( array_key_exists( $productId, $seen ) ) {
				$message = sprintf(
					'InMemoryEmbedItemRepository::replaceEmbedItems() rejects duplicate productId %d.',
					$productId
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$seen[ $productId ] = true;

			$normalized[] = $productId;
		}

		// Remove every existing row for the embed.
		$kept = array();
		foreach ( $this->rows as $row ) {
			if ( $row['embed_id'] !== $embedId ) {
				$kept[] = $row;
			}
		}
		$this->rows = $kept;

		// Insert the supplied rows with positional sort_order.
		foreach ( $normalized as $sortOrder => $productId ) {
			$this->rows[] = array(
				'embed_id'   => $embedId,
				'product_id' => $productId,
				'sort_order' => $sortOrder,
			);
		}

		return count( $normalized );
	}

	/**
	 * Remove every junction row for the supplied embed id.
	 *
	 * Idempotent: returns the count of removed rows.
	 *
	 * @param int $embedId Positive `clb_embeds.id`.
	 *
	 * @throws InvalidArgumentException When `$embedId` is non-positive.
	 */
	public function deleteByEmbedId( int $embedId ): int {
		if ( $embedId <= 0 ) {
			$message = sprintf(
				'InMemoryEmbedItemRepository::deleteByEmbedId() requires a positive embedId, got %d.',
				$embedId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$kept    = array();
		$removed = 0;
		foreach ( $this->rows as $row ) {
			if ( $row['embed_id'] === $embedId ) {
				++$removed;
				continue;
			}
			$kept[] = $row;
		}
		$this->rows = $kept;

		return $removed;
	}

	/**
	 * Total number of junction rows currently stored.
	 */
	public function count(): int {
		return count( $this->rows );
	}

	/**
	 * Return every stored row, ordered by ascending embed id and
	 * sort_order.
	 *
	 * Exposed purely as a convenience for test assertions that
	 * want to enumerate the repository contents without driving a
	 * lookup. Production code MUST NOT rely on this method
	 * because it has no counterpart on the
	 * {@see EmbedItemRepository} contract and the Wpdb-backed
	 * implementation does not offer it.
	 *
	 * @return list<array{embed_id: int, product_id: int, sort_order: int}>
	 */
	public function all(): array {
		$ordered = $this->rows;
		usort(
			$ordered,
			static function ( array $a, array $b ): int {
				$cmp = $a['embed_id'] <=> $b['embed_id'];
				if ( $cmp !== 0 ) {
					return $cmp;
				}
				return $a['sort_order'] <=> $b['sort_order'];
			}
		);
		return array_values( $ordered );
	}
}
